rigortype 0.3.6 → 0.3.7

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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/manual/02-cli-reference.md +16 -7
  15. data/docs/manual/05-inspecting-types.md +20 -8
  16. data/docs/manual/07-plugins.md +4 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  18. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  20. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  21. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  22. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  23. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  24. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  28. data/lib/rigor/analysis/check_rules.rb +383 -48
  29. data/lib/rigor/analysis/crash_signature.rb +116 -0
  30. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  32. data/lib/rigor/analysis/incremental.rb +28 -0
  33. data/lib/rigor/analysis/incremental_session.rb +44 -9
  34. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  35. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  36. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  37. data/lib/rigor/analysis/result.rb +32 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  43. data/lib/rigor/analysis/runner.rb +147 -10
  44. data/lib/rigor/analysis/worker_session.rb +10 -1
  45. data/lib/rigor/bleeding_edge.rb +7 -6
  46. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  47. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  48. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  49. data/lib/rigor/cache/descriptor.rb +42 -2
  50. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  51. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  52. data/lib/rigor/cache/store.rb +10 -1
  53. data/lib/rigor/cli/coverage_command.rb +23 -18
  54. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  55. data/lib/rigor/cli/coverage_scan.rb +47 -6
  56. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  57. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  58. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  59. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  60. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  61. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  62. data/lib/rigor/cli/type_of_command.rb +188 -41
  63. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  64. data/lib/rigor/cli/type_scan_command.rb +10 -1
  65. data/lib/rigor/cli.rb +1 -1
  66. data/lib/rigor/configuration.rb +7 -4
  67. data/lib/rigor/effects/plugin_facts.rb +1 -1
  68. data/lib/rigor/effects/scanner.rb +2 -3
  69. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  70. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  71. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  72. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  73. data/lib/rigor/environment/rbs_loader.rb +525 -46
  74. data/lib/rigor/environment.rb +38 -15
  75. data/lib/rigor/inference/acceptance.rb +172 -20
  76. data/lib/rigor/inference/captured_locals.rb +62 -0
  77. data/lib/rigor/inference/content_join.rb +347 -0
  78. data/lib/rigor/inference/def_handle.rb +15 -3
  79. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  80. data/lib/rigor/inference/expression_typer.rb +1020 -141
  81. data/lib/rigor/inference/fork_map.rb +6 -1
  82. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  83. data/lib/rigor/inference/hkt_registry.rb +46 -0
  84. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  85. data/lib/rigor/inference/index_write_widening.rb +48 -0
  86. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  89. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  93. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  95. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  96. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  97. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  98. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  99. data/lib/rigor/inference/mutation_widening.rb +244 -174
  100. data/lib/rigor/inference/narrowing.rb +331 -27
  101. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  102. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  103. data/lib/rigor/inference/precision_scanner.rb +5 -2
  104. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  105. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  106. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  107. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  108. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  109. data/lib/rigor/inference/version_guard.rb +229 -0
  110. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  111. data/lib/rigor/plugin/base.rb +3 -2
  112. data/lib/rigor/plugin/inflector.rb +14 -5
  113. data/lib/rigor/plugin/io_boundary.rb +107 -4
  114. data/lib/rigor/plugin/loader.rb +14 -0
  115. data/lib/rigor/protection/analysis_guard.rb +60 -0
  116. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  117. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  118. data/lib/rigor/protection/discovery_seed.rb +1 -1
  119. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  120. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  121. data/lib/rigor/protection/mutator.rb +121 -18
  122. data/lib/rigor/reflection.rb +182 -30
  123. data/lib/rigor/scope/discovery_index.rb +39 -1
  124. data/lib/rigor/scope.rb +385 -4
  125. data/lib/rigor/sig_gen/classification.rb +12 -1
  126. data/lib/rigor/sig_gen/generator.rb +254 -5
  127. data/lib/rigor/sig_gen/renderer.rb +1 -6
  128. data/lib/rigor/sig_gen/writer.rb +3 -0
  129. data/lib/rigor/source/constant_path.rb +79 -2
  130. data/lib/rigor/triage/catalogue.rb +1 -1
  131. data/lib/rigor/type/combinator.rb +10 -0
  132. data/lib/rigor/type/maybe.rb +47 -0
  133. data/lib/rigor/type/refined.rb +1 -2
  134. data/lib/rigor/type/result.rb +53 -0
  135. data/lib/rigor/type.rb +2 -0
  136. data/lib/rigor/version.rb +1 -1
  137. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  139. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  140. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  141. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  142. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  147. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  148. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  150. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  151. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  152. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  153. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  154. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  156. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  157. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  159. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  160. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  163. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  164. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  165. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  166. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  167. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  168. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  169. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  170. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  172. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  173. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  174. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  175. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  176. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  177. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  178. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  179. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  180. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  181. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  182. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  183. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  184. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  185. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  186. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  187. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  188. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  189. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  190. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  191. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  192. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  193. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  194. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  195. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  196. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  200. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  201. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  202. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  203. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  204. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  205. data/sig/rigor/analysis/baseline.rbs +1 -1
  206. data/sig/rigor/environment.rbs +3 -0
  207. data/sig/rigor/inference.rbs +4 -1
  208. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  209. data/sig/rigor/reflection.rbs +3 -1
  210. data/sig/rigor/scope.rbs +25 -1
  211. data/sig/rigor/type.rbs +95 -1
  212. data/sig/rigor.rbs +16 -0
  213. metadata +69 -3
@@ -9,6 +9,7 @@ require_relative "../source/node_children"
9
9
  require_relative "../cache/file_digest"
10
10
  require_relative "anonymous_meta_class"
11
11
  require_relative "def_handle"
12
+ require_relative "index_write_widening"
12
13
  require_relative "mutation_widening"
13
14
  require_relative "narrowing"
14
15
  require_relative "statement_evaluator"
@@ -37,8 +38,14 @@ module Rigor
37
38
  # Nodes that are not part of the program subtree (e.g. synthesised virtual nodes that the caller looks up after the
38
39
  # fact) yield the `default_scope`. The returned Hash is mutable in principle but callers MUST treat it as read-only;
39
40
  # the indexer itself never exposes a way to update it past construction.
40
- # rubocop:disable Metrics/ModuleLength
41
+ # rubocop:disable-next Metrics/ModuleLength
41
42
  module ScopeIndexer
43
+ # Issue #644 — the census descriptor for a constant write that publishes no value: an unfoldable or
44
+ # non-literal rvalue, an operator / multi-assign / dynamic-path form, or a name this file writes twice.
45
+ # A Symbol, so it is distinguishable from a `[literal]` descriptor by class alone and rides the ADR-85
46
+ # seed bundle through `Marshal` unchanged.
47
+ CONSTANT_UNPUBLISHABLE = :unpublishable
48
+
42
49
  module_function
43
50
 
44
51
  # Build the scope index for a Prism program subtree.
@@ -78,7 +85,14 @@ module Rigor
78
85
  # Slice 7 phase 6. Same pre-pass shape for cvars (per class) and globals (program-wide). Globals are also
79
86
  # materialised into the top-level scope's `globals` map so reads at the top level (and in CLI probes that do not
80
87
  # enter a method body) observe the precise type without consulting the accumulator on every lookup.
81
- class_cvars = build_class_cvar_index(root, seeded_scope)
88
+ # Issue #540 — a literal-shape carrier assigned to a constant or class variable is only as good as
89
+ # the file's OWN mutations of it: `ISPELL_STATUS = {}` with a sibling method writing
90
+ # `ISPELL_STATUS[:key] = param` must not fold reads through the closed empty shape. One census walk
91
+ # collects the mutated names; the two accumulators below widen their entries to the Dynamic-wrapped
92
+ # form so reads stay honest without licensing the negative rules.
93
+ literal_mutations = collect_literal_receiver_mutations(root)
94
+
95
+ class_cvars = widen_mutated_cvars(build_class_cvar_index(root, seeded_scope), literal_mutations[:cvars])
82
96
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
83
97
  program_globals = build_program_global_index(root, seeded_scope)
84
98
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(program_globals: program_globals))
@@ -92,9 +106,10 @@ module Rigor
92
106
  # `pre_eval:` constant seed `Runner#project_scope_seed_tables` applies). Same-file declarations are the
93
107
  # most specific authority, exactly as `merged_classes` above resolves the same collision. Without the
94
108
  # merge, the assignment below would silently drop the project seed on every file.
95
- in_source_constants = build_in_source_constants(root, seeded_scope)
96
- merged_constants = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
97
- seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(in_source_constants: merged_constants))
109
+ in_source_constants = widen_mutated_constants(
110
+ build_in_source_constants(root, seeded_scope), literal_mutations[:constants]
111
+ )
112
+ seeded_scope = seed_constant_tables(seeded_scope, default_scope, in_source_constants, root)
98
113
 
99
114
  # Slice 7 phase 12. In-source method discovery. Walks every class/module body for `Prism::DefNode` and
100
115
  # recognised `define_method` calls and records the introduced method names. `rigor check` consults the table to
@@ -158,15 +173,11 @@ module Rigor
158
173
  # is merged UNDER the cross-file `discovered_def_index_for_paths` seed carried on `default_scope` — same-file
159
174
  # declarations win per entry, the cross-file seed supplies sibling-file ancestors.
160
175
  def merge_project_method_indexes(seeded_scope, default_scope, root, file_def_nodes)
161
- def_nodes = default_scope.discovered_def_nodes.merge(
162
- file_def_nodes
163
- ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
176
+ def_nodes, def_nestings = merge_def_node_tables(default_scope, root, file_def_nodes)
164
177
  singleton_def_nodes = default_scope.discovered_singleton_def_nodes.merge(
165
178
  build_discovered_singleton_def_nodes(root)
166
179
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
167
- superclasses = default_scope.discovered_superclasses.merge(
168
- build_discovered_superclasses(root, default_scope.source_path)
169
- )
180
+ superclasses, header_nestings = merge_ancestry_tables(default_scope, root)
170
181
  includes = default_scope.discovered_includes.merge(
171
182
  build_discovered_includes(root)
172
183
  ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
@@ -179,11 +190,19 @@ module Rigor
179
190
  # authoritative).
180
191
  data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, root)
181
192
 
193
+ # #526 — this file's extends folded against the MERGED instance def-nodes (so `extend M` sees a
194
+ # sibling-file M through the cross-file seed). The project-wide fold in {#finalize_def_index}
195
+ # covers cross-file CONSUMERS; this covers the file that declares the extend.
196
+ methods_table = fold_per_file_extends(root, def_nodes, singleton_def_nodes, seeded_scope)
197
+
182
198
  seeded_scope.with_discovery(
183
199
  seeded_scope.discovery.with(
200
+ discovered_methods: methods_table,
184
201
  discovered_def_nodes: def_nodes,
202
+ discovered_def_nestings: def_nestings,
185
203
  discovered_singleton_def_nodes: singleton_def_nodes,
186
204
  discovered_superclasses: superclasses,
205
+ discovered_header_nestings: header_nestings,
187
206
  discovered_includes: includes,
188
207
  discovered_method_visibilities: method_visibilities,
189
208
  data_member_layouts: data_member_layouts,
@@ -192,6 +211,54 @@ module Rigor
192
211
  )
193
212
  end
194
213
 
214
+ # The as-written superclass table and its issue #682 header-nesting twin, each merged over the cross-file
215
+ # seed. Returned as a pair for the same reason {#merge_def_node_tables} is: both come from ONE walk of the
216
+ # file, so a caller cannot pair a superclass name with a nesting recorded by a different parse.
217
+ def merge_ancestry_tables(default_scope, root)
218
+ file_superclasses, file_header_nestings = build_superclass_tables(root, default_scope.source_path)
219
+ [default_scope.discovered_superclasses.merge(file_superclasses),
220
+ merge_header_nestings(default_scope.discovery.discovered_header_nestings.dup,
221
+ file_header_nestings).freeze]
222
+ end
223
+
224
+ # The instance-side def-node table and its issue #681 nesting twin, each merged over the cross-file seed.
225
+ # Returned as a pair so the two stay written together: a node the merge keeps must be the same object the
226
+ # nesting table keys, and they are only that if both halves take the same file's walk.
227
+ def merge_def_node_tables(default_scope, root, file_def_nodes)
228
+ def_nodes = default_scope.discovered_def_nodes.merge(
229
+ file_def_nodes
230
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
231
+ [def_nodes,
232
+ merge_def_nestings(default_scope.discovery.discovered_def_nestings, build_def_nestings(root))]
233
+ end
234
+
235
+ # Issue #681 — the per-file nesting table over the cross-file seed. Both are keyed by node identity, so
236
+ # a same-file declaration and its cross-file twin are distinct keys and the merge order is immaterial;
237
+ # the copy exists only so the seed stays frozen. Skipped when the file declares no `def` at all — since
238
+ # issue #716 a top-level `def` records its empty chain, so a file of plain top-level helpers no longer
239
+ # takes that path and pays one copy of the seed, the same as any file that declares a method.
240
+ def merge_def_nestings(seed, file_nestings)
241
+ return seed if file_nestings.empty?
242
+ return file_nestings if seed.empty?
243
+
244
+ merged = {}.compare_by_identity
245
+ merged.merge!(seed)
246
+ merged.merge!(file_nestings)
247
+ merged.freeze
248
+ end
249
+
250
+ # The per-file half of the #526 fold: mutable copies of the merged tables take the extends, and the
251
+ # existence table (already seeded onto the scope) is rebuilt only when the fold touched it.
252
+ def fold_per_file_extends(root, def_nodes, singleton_def_nodes, seeded_scope)
253
+ extends = build_discovered_extends(root)
254
+ methods_table = seeded_scope.discovered_methods
255
+ return methods_table if extends.empty?
256
+
257
+ mutable_methods = methods_table.transform_values(&:dup)
258
+ fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, mutable_methods)
259
+ mutable_methods
260
+ end
261
+
195
262
  # ADR-48 — the per-file Data + Struct member-layout tables, each merged OVER the cross-file seed so a same-file
196
263
  # declaration wins for its own classes. Returned as a pair to keep {#merge_project_method_indexes} under the
197
264
  # method-size budget.
@@ -220,6 +287,7 @@ module Rigor
220
287
  method_assign_effects = build_method_assign_effects(root)
221
288
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
222
289
  read_before_write, init_writes, method_assign_effects)
290
+ record_aliased_ivar_mutations!(root, mutated_ivars)
223
291
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
224
292
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
225
293
  accumulator.transform_values(&:freeze).freeze
@@ -261,6 +329,119 @@ module Rigor
261
329
  end
262
330
  end
263
331
 
332
+ # An ivar a method RETURNS leaves the object, and every mutation of the returned reference mutates the ivar:
333
+ # `(bucket_for(entry)[key] ||= {})[name] = row` fills whichever of `@self_rows` / `@path_rows` / `@result_rows`
334
+ # `bucket_for` handed back. {record_ivar_mutator_call} cannot see it — the mutation's receiver is a `CallNode`,
335
+ # not an `InstanceVariableReadNode` — so those ivars kept the empty `HashShape` their `initialize` gave them and
336
+ # `@path_rows.empty?` folded to `Constant[true]` on a hash the class fills. Rigor's own
337
+ # `lib/rigor/effects/plugin_facts.rb` is the worked site.
338
+ #
339
+ # Scope is deliberately narrow: the mutation receiver must be a SELF-call (an explicit receiver is a different
340
+ # object), the callee must be an instance method of the same class, and only ivars in the callee's RETURN
341
+ # position count — an ivar merely read in its body (`@rows[owner]` returns the value, not the hash) does not
342
+ # escape. Over-recording here would widen a shape carrier that nothing mutates, which costs precision on every
343
+ # reader in the class.
344
+ #
345
+ # Runs after the main walk so the returned-ivar summary is complete regardless of definition order, and gated on
346
+ # a class actually having a returning method — the common class contributes nothing and pays one `defs` lookup.
347
+ def record_aliased_ivar_mutations!(root, mutated_ivars)
348
+ defs = collect_class_method_defs(root)
349
+ returned = returned_ivars_by_method(defs)
350
+ return if returned.empty?
351
+
352
+ defs.each do |class_name, methods|
353
+ per_class = returned[class_name]
354
+ next if per_class.nil?
355
+
356
+ methods.each_value do |def_node|
357
+ gather_aliased_mutations(def_node.body, class_name, per_class, mutated_ivars)
358
+ end
359
+ end
360
+ end
361
+
362
+ # `{class_name => {method_name => Set<ivar name>}}` for methods that hand an ivar back, dropping the empty sets
363
+ # so the caller's `next if per_class.nil?` skips a whole class in one lookup.
364
+ def returned_ivars_by_method(defs)
365
+ defs.each_with_object({}) do |(class_name, methods), acc|
366
+ methods.each do |method_name, def_node|
367
+ ivars = returned_ivars(def_node)
368
+ (acc[class_name] ||= {})[method_name] = ivars unless ivars.empty?
369
+ end
370
+ end
371
+ end
372
+
373
+ # The ivars `def_node` can hand back: an explicit `return @x` anywhere in the body, and the body's tail
374
+ # expression when it is (or resolves through branch nodes to) a bare ivar read. A tail that computes something
375
+ # FROM an ivar (`@rows[k]`, `@rows.dup`) hands back a different object and is not an escape.
376
+ def returned_ivars(def_node)
377
+ body = def_node.body
378
+ return EMPTY_IVAR_SET if body.nil?
379
+
380
+ found = Set.new
381
+ collect_explicit_return_ivars(body, found)
382
+ tail_value_nodes(body).each { |node| found << node.name if node.is_a?(Prism::InstanceVariableReadNode) }
383
+ found
384
+ end
385
+
386
+ def collect_explicit_return_ivars(node, found)
387
+ return unless node.is_a?(Prism::Node)
388
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
389
+
390
+ if node.is_a?(Prism::ReturnNode)
391
+ args = node.arguments&.arguments
392
+ found << args.first.name if args && args.size == 1 && args.first.is_a?(Prism::InstanceVariableReadNode)
393
+ end
394
+
395
+ node.rigor_each_child { |c| collect_explicit_return_ivars(c, found) }
396
+ end
397
+
398
+ # The expressions a body can evaluate to, resolving through the branch nodes whose value is a sub-expression.
399
+ # Depth-capped for the same reason every other walk here is: a pathologically nested tail is not worth unbounded
400
+ # recursion, and stopping early only under-collects (fewer escapes recorded, never more).
401
+ def tail_value_nodes(node, depth = 0)
402
+ return [] unless node.is_a?(Prism::Node)
403
+ return [] if depth >= TAIL_VALUE_DEPTH_CAP
404
+
405
+ children = tail_value_children(node)
406
+ return [node] if children.nil?
407
+
408
+ children.flat_map { |child| tail_value_nodes(child, depth + 1) }
409
+ end
410
+
411
+ # The children a node's own value comes from, or nil when the node IS the value. Split out from
412
+ # {tail_value_nodes} so the recursion stays one line and this stays a lookup table.
413
+ def tail_value_children(node)
414
+ case node
415
+ when Prism::StatementsNode then [node.body.last]
416
+ when Prism::ParenthesesNode then [node.body]
417
+ when Prism::BeginNode, Prism::ElseNode, Prism::WhenNode, Prism::InNode then [node.statements]
418
+ when Prism::IfNode then [node.statements, node.subsequent]
419
+ when Prism::UnlessNode then [node.statements, node.else_clause]
420
+ when Prism::CaseNode then node.conditions + [node.else_clause]
421
+ when Prism::OrNode, Prism::AndNode then [node.left, node.right]
422
+ end
423
+ end
424
+
425
+ # Records every mutation in `node` whose receiver is a self-call to one of `per_class`'s returning methods.
426
+ def gather_aliased_mutations(node, class_name, per_class, mutated_ivars)
427
+ return unless node.is_a?(Prism::Node)
428
+
429
+ mutator, receiver = mutation_target(node)
430
+ escaped_ivars_for_receiver(receiver, per_class)&.each do |ivar_name|
431
+ per_ivar = ((mutated_ivars[class_name] ||= {})[ivar_name] ||= Set.new)
432
+ per_ivar << mutator
433
+ end
434
+
435
+ node.rigor_each_child { |c| gather_aliased_mutations(c, class_name, per_class, mutated_ivars) }
436
+ end
437
+
438
+ # The ivars a mutation receiver can be, when the receiver is a same-class self-call. Nil for every other shape.
439
+ def escaped_ivars_for_receiver(receiver, per_class)
440
+ return nil unless receiver.is_a?(Prism::CallNode) && receiver.receiver.nil?
441
+
442
+ per_class[receiver.name]
443
+ end
444
+
264
445
  # Walks the post-collected accumulator and widens any Tuple / HashShape entry for an ivar that observed a mutator
265
446
  # call anywhere in the same class body. The mutation evidence comes from `gather_ivar_writes` recording every
266
447
  # `@ivar.<method>(...)` call whose method is in `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
@@ -317,15 +498,64 @@ module Rigor
317
498
  end
318
499
  end
319
500
 
501
+ # Issue #681 — the scope a census pre-pass types an rvalue under. The census walks never enter a body
502
+ # through `StatementEvaluator`, so nothing stamped the declaration's `Module.nesting` on the scope they
503
+ # build and `Reflection.lexical_nesting_chain` fell back to peeling `self_type`'s qualified name. That
504
+ # peel cannot tell a compact `class Admin::Census` (nesting `[Admin::Census]`, so a bare `Post` names
505
+ # `::Post`) from the nested spelling (nesting `[Admin::Census, Admin]`, where it names `Admin::Post`),
506
+ # so `@post = Post.new` and `DEFAULT = Post` were recorded under the wrong class and every later read
507
+ # answered it. The prefix these walks already thread IS the chain — `["Admin::Census"]` for the compact
508
+ # form, `["Admin", "Census"]` for the nested one — so the fact was in hand and only the stamp was
509
+ # missing.
510
+ def census_body_scope(default_scope, qualified_prefix, self_type)
511
+ default_scope.with_self_type(self_type)
512
+ .with_lexical_nesting(census_nesting(default_scope, qualified_prefix))
513
+ end
514
+
515
+ # The chain a census walk answers `Module.nesting` with: the one {#scope_entering_declaration} threaded
516
+ # on the scope when a header was entered, and only failing that the prefix-derived approximation below.
517
+ def census_nesting(scope, qualified_prefix)
518
+ scope&.lexical_nesting || lexical_nesting_for_prefix(qualified_prefix)
519
+ end
520
+
521
+ # The scope a census walk threads into the body a `class` / `module` header opens: the scope it already
522
+ # threads, carrying that body's real `Module.nesting`.
523
+ #
524
+ # Issue #708 — the qualified prefix stopped being sufficient to DERIVE that chain. A rooted header
525
+ # RESETS the prefix (`class ::Rooted` inside `module Outer` prefixes `["Rooted"]`), so `Outer` is absent
526
+ # from it entirely and no function of the prefix alone recovers it, while Ruby keeps `Outer` on the
527
+ # ladder as a live rung. `Source::ConstantPath.pushed_nesting` — the one function both declaration walks
528
+ # already push with — has that case right, so the chain travels on the scope these walks thread anyway
529
+ # rather than through every walk's parameter list, and stays a single owner ([#652](https://github.com/rigortype/rigor/issues/652)).
530
+ def scope_entering_declaration(default_scope, constant_path)
531
+ return default_scope if default_scope.nil?
532
+
533
+ chain = Source::ConstantPath.pushed_nesting(default_scope.lexical_nesting || EMPTY_NESTING,
534
+ constant_path)
535
+ chain ? default_scope.with_lexical_nesting(chain) : default_scope
536
+ end
537
+
538
+ # Ruby's `Module.nesting` for a body enclosed by `qualified_prefix`, innermost first: each entry is the
539
+ # prefix truncated at one declaration, joined, so the compact spelling contributes exactly one entry and
540
+ # the nested spelling one per `class` / `module` keyword.
541
+ #
542
+ # **This is an approximation, and only correct when no ROOTED header encloses the body** — a rooted
543
+ # header resets the prefix without resetting Ruby's nesting, so the entries beneath the reset are not
544
+ # in the prefix to be truncated out of it (#708). It remains the answer for a caller that threaded no
545
+ # chain, where it is what the code did before and never a new firing; the walks that reach a body
546
+ # thread the real chain via {#scope_entering_declaration}.
547
+ def lexical_nesting_for_prefix(qualified_prefix)
548
+ (1..qualified_prefix.size).map { |n| qualified_prefix.first(n).join("::") }.reverse.freeze
549
+ end
550
+
320
551
  def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists
321
552
  read_before_write = nil, init_writes = nil, method_assign_effects = nil)
322
553
  return unless node.is_a?(Prism::Node)
323
554
 
324
555
  case node
325
556
  when Prism::ClassNode, Prism::ModuleNode
326
- name = Source::ConstantPath.qualified_name(node.constant_path)
327
- if name
328
- child_prefix = qualified_prefix + [name]
557
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
558
+ if child_prefix
329
559
  if node.body
330
560
  # Class-body level `@x = nil` writes don't initialise instance ivars at runtime (the class's own singleton
331
561
  # ivars and the instance's ivars are separate stores), but they signal "the author KNOWS @x could be nil"
@@ -335,7 +565,8 @@ module Rigor
335
565
  # class-body level then writes `@x = SomeClass.new` inside an instance method gains an unjustified nil
336
566
  # widening at every read.
337
567
  collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
338
- walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
568
+ walk_class_ivars(node.body, child_prefix,
569
+ scope_entering_declaration(default_scope, node.constant_path), accumulator,
339
570
  mutated_ivars, read_before_write, init_writes, method_assign_effects)
340
571
  end
341
572
  return
@@ -372,7 +603,7 @@ module Rigor
372
603
  else
373
604
  Type::Combinator.nominal_of(class_name)
374
605
  end
375
- body_scope = default_scope.with_self_type(self_type)
606
+ body_scope = census_body_scope(default_scope, qualified_prefix, self_type)
376
607
 
377
608
  # C2 — transient `@x = nil` dead-write elimination. When a method body opens with an unconditional `@x = nil`
378
609
  # (defensive init) and then *definitely* reassigns `@x` to a non-nil value on every completing path (a later
@@ -404,7 +635,7 @@ module Rigor
404
635
 
405
636
  class_name = qualified_prefix.join("::")
406
637
  self_type = Type::Combinator.nominal_of(class_name)
407
- body_scope = default_scope.with_self_type(self_type)
638
+ body_scope = census_body_scope(default_scope, qualified_prefix, self_type)
408
639
 
409
640
  gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
410
641
  EMPTY_GUARDED_IVARS, mutated_ivars)
@@ -576,6 +807,14 @@ module Rigor
576
807
  private_constant :IVAR_BARRIER_NODES
577
808
 
578
809
  EMPTY_GUARDED_IVARS = Set.new.freeze
810
+
811
+ # Shared empty result for a def that returns no ivar — the overwhelmingly common case.
812
+ EMPTY_IVAR_SET = Set.new.freeze
813
+ private_constant :EMPTY_IVAR_SET
814
+
815
+ # Branch nesting a tail expression is followed through. Stopping early only UNDER-collects escapes.
816
+ TAIL_VALUE_DEPTH_CAP = 8
817
+ private_constant :TAIL_VALUE_DEPTH_CAP
579
818
  private_constant :EMPTY_GUARDED_IVARS
580
819
 
581
820
  def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
@@ -597,7 +836,7 @@ module Rigor
597
836
  # records — an unanalyzable multi-write means unknown, not nil).
598
837
  record_multi_write_ivars(node, scope, class_name, accumulator)
599
838
 
600
- record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
839
+ record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars
601
840
 
602
841
  # Don't recurse into nested defs, classes, or modules; their ivars belong to their own enclosing class.
603
842
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
@@ -618,14 +857,33 @@ module Rigor
618
857
  # {.widen_mutated_ivar_entries!}). Always-safe to over- collect: any name that the widening primitive declines is
619
858
  # ignored at finalization.
620
859
  def record_ivar_mutator_call(node, class_name, mutated_ivars)
621
- receiver = node.receiver
860
+ method_name, receiver = mutation_target(node)
861
+ return if method_name.nil?
622
862
  return unless receiver.is_a?(Prism::InstanceVariableReadNode)
623
- return unless MutationWidening::ARRAY_MUTATORS.include?(node.name) ||
624
- MutationWidening::HASH_MUTATORS.include?(node.name)
863
+ return unless MutationWidening::ARRAY_MUTATORS.include?(method_name) ||
864
+ MutationWidening::HASH_MUTATORS.include?(method_name)
625
865
 
626
866
  per_class = (mutated_ivars[class_name] ||= {})
627
867
  per_ivar = (per_class[receiver.name] ||= Set.new)
628
- per_ivar << node.name
868
+ per_ivar << method_name
869
+ end
870
+
871
+ # `[mutator name, receiver node]` for a node that mutates its receiver, `nil` otherwise.
872
+ #
873
+ # `@h[k] ||= v` and its `&&=` / `+=` siblings store through `[]=` but are not `[]=` CallNodes; missing them left
874
+ # an `@h = {}` mutated only that way carrying its empty `HashShape` into every sibling method, so `@h.empty?`
875
+ # folded to `Constant[true]` on a hash the class fills. `IndexWriteWidening::NODE_CLASSES` owns that list; it is
876
+ # spelled out here as an explicit disjunction because that is what narrows `node` to something with a
877
+ # `#receiver` — `NODE_CLASSES.any? { … }` reads as a call on `Prism::Node` and Rigor rejects it, correctly.
878
+ def mutation_target(node)
879
+ return [node.name, node.receiver] if node.is_a?(Prism::CallNode)
880
+
881
+ if node.is_a?(Prism::IndexOrWriteNode) || node.is_a?(Prism::IndexAndWriteNode) ||
882
+ node.is_a?(Prism::IndexOperatorWriteNode)
883
+ return [IndexWriteWidening::MUTATOR, node.receiver]
884
+ end
885
+
886
+ nil
629
887
  end
630
888
 
631
889
  # Walk an `IfNode` / `UnlessNode` so writes inside the THEN body that look like defensive ivar initialisation gain
@@ -771,11 +1029,8 @@ module Rigor
771
1029
 
772
1030
  case root
773
1031
  when Prism::ClassNode, Prism::ModuleNode
774
- name = Source::ConstantPath.qualified_name(root.constant_path)
775
- if name && root.body
776
- child = prefix + [name]
777
- collect_class_method_defs(root.body, child, acc)
778
- end
1032
+ child = Source::ConstantPath.declaration_prefix(prefix, root.constant_path)
1033
+ collect_class_method_defs(root.body, child, acc) if child && root.body
779
1034
  return acc
780
1035
  when Prism::DefNode
781
1036
  (acc[prefix.join("::")] ||= {})[root.name] = root unless prefix.empty? || root.receiver
@@ -1135,10 +1390,12 @@ module Rigor
1135
1390
 
1136
1391
  case node
1137
1392
  when Prism::ClassNode, Prism::ModuleNode
1138
- name = Source::ConstantPath.qualified_name(node.constant_path)
1139
- if name
1140
- child_prefix = qualified_prefix + [name]
1141
- walk_class_cvars(node.body, child_prefix, default_scope, accumulator) if node.body
1393
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
1394
+ if child_prefix
1395
+ if node.body
1396
+ walk_class_cvars(node.body, child_prefix,
1397
+ scope_entering_declaration(default_scope, node.constant_path), accumulator)
1398
+ end
1142
1399
  return
1143
1400
  end
1144
1401
  when Prism::DefNode
@@ -1155,7 +1412,8 @@ module Rigor
1155
1412
  return if def_node.body.nil? || qualified_prefix.empty?
1156
1413
 
1157
1414
  class_name = qualified_prefix.join("::")
1158
- body_scope = default_scope.with_self_type(Type::Combinator.nominal_of(class_name))
1415
+ body_scope = census_body_scope(default_scope, qualified_prefix,
1416
+ Type::Combinator.nominal_of(class_name))
1159
1417
  gather_cvar_writes(def_node.body, body_scope, class_name, accumulator)
1160
1418
  end
1161
1419
 
@@ -1202,58 +1460,422 @@ module Rigor
1202
1460
  # Slice 7 phase 9 — in-source constant value pre-pass. Walks the entire program (top-level AND inside class /
1203
1461
  # module / def bodies) for `Prism::ConstantWriteNode` and `Prism::ConstantPathWriteNode`, types each rvalue, and
1204
1462
  # accumulates by qualified name. Constants defined inside a class body are qualified with the surrounding class
1205
- # path; constants written via a path (`Foo::BAR = ...`) use the rendered path as-is.
1463
+ # path; a constant written via a path resolves its own namespace through the enclosing nesting first
1464
+ # ({#constant_path_write_key}) — which is where this walk and the
1465
+ # [#644](https://github.com/rigortype/rigor/issues/644) publication census below stopped agreeing about
1466
+ # a path write's name.
1206
1467
  def build_in_source_constants(root, default_scope)
1207
1468
  accumulator = {}
1208
1469
  walk_constant_writes(root, [], default_scope, accumulator)
1209
1470
  accumulator.freeze
1210
1471
  end
1211
1472
 
1473
+ # Issue #540 — the mutation census behind the two wideners. Walks the whole tree once, tracking the
1474
+ # lexical class/module prefix, and records every node that MUTATES a constant-read or
1475
+ # class-variable-read receiver: the `Index{Or,And,Operator}Write` family, and a CallNode whose name
1476
+ # is a known in-place mutator (`MutationWidening`'s tables) or a plain attribute/index writer
1477
+ # (`name=` / `[]=`). Non-mutating reads never register, so `VERSION`-style constants keep their fold.
1478
+ #
1479
+ # @return [Hash{Symbol => Object}] `:constants` — a Set of candidate qualified names (each mutation
1480
+ # contributes every lexical-resolution candidate, `A::B::C` outward to bare `C`, mirroring how the
1481
+ # reads resolve); `:cvars` — `{class_name => Set[Symbol]}`.
1482
+ def collect_literal_receiver_mutations(root)
1483
+ census = { constants: Set.new, cvars: Hash.new { |h, k| h[k] = Set.new } }
1484
+ walk_literal_receiver_mutations(root, [], census, EMPTY_NESTING)
1485
+ census
1486
+ end
1487
+
1488
+ def walk_literal_receiver_mutations(node, qualified_prefix, census, nesting = EMPTY_NESTING)
1489
+ return unless node.is_a?(Prism::Node)
1490
+
1491
+ case node
1492
+ when Prism::ClassNode, Prism::ModuleNode
1493
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
1494
+ if child_prefix
1495
+ if node.body
1496
+ walk_literal_receiver_mutations(node.body, child_prefix, census,
1497
+ Source::ConstantPath.pushed_nesting(nesting,
1498
+ node.constant_path) || nesting)
1499
+ end
1500
+ return
1501
+ end
1502
+ else
1503
+ record_literal_receiver_mutation(node, qualified_prefix, nesting, census)
1504
+ end
1505
+
1506
+ node.rigor_each_child { |child| walk_literal_receiver_mutations(child, qualified_prefix, census, nesting) }
1507
+ end
1508
+
1509
+ # Two parameters because the two arms key on two DIFFERENT TABLES, not because the values differ:
1510
+ # `nesting.first` and `qualified_prefix.join("::")` are in fact always equal, since
1511
+ # `.declaration_prefix` and `.pushed_nesting` branch on the same `rooted?` and qualify against the same
1512
+ # parent. `qualified_prefix` is what a class variable must key on because it is the join key with
1513
+ # `build_class_cvar_index`, which derives its own key from the same `declaration_prefix` — so the two
1514
+ # tables agree by construction. `nesting` is what a constant name must resolve through because the
1515
+ # ladder walks every rung, not just the innermost, and a rooted header drops the outer rungs from the
1516
+ # prefix while Ruby keeps them ([#708](https://github.com/rigortype/rigor/issues/708)).
1517
+ def record_literal_receiver_mutation(node, qualified_prefix, nesting, census)
1518
+ receiver = mutating_receiver_of(node)
1519
+ return if receiver.nil?
1520
+
1521
+ case receiver
1522
+ when Prism::ConstantReadNode
1523
+ constant_mutation_candidates(receiver.name.to_s, nesting, census[:constants])
1524
+ when Prism::ConstantPathNode
1525
+ # The lexical candidates a PATH spelling reaches, for the same reason the bare arm above takes
1526
+ # them: `Holder::TABLE[:k] = 1` inside `module Admin` mutates whatever `Holder::TABLE` resolves
1527
+ # to there, and since [#690](https://github.com/rigortype/rigor/issues/690) the write accumulator
1528
+ # keys that entry `Admin::Holder::TABLE`. Recording only the as-written name left the mutated
1529
+ # constant matched by nothing and its closed empty shape intact — the very fold #540 exists to
1530
+ # retract. Over-recording only widens, which is the direction this census is allowed to err in.
1531
+ full = Source::ConstantPath.qualified_name_or_nil(receiver)
1532
+ constant_mutation_candidates(full, nesting, census[:constants]) if full
1533
+ when Prism::ClassVariableReadNode
1534
+ census[:cvars][qualified_prefix.join("::")] << receiver.name unless qualified_prefix.empty?
1535
+ end
1536
+ end
1537
+
1538
+ # The receiver a node mutates, or nil when the node is not a mutation.
1539
+ def mutating_receiver_of(node)
1540
+ case node
1541
+ when Prism::IndexOrWriteNode, Prism::IndexAndWriteNode, Prism::IndexOperatorWriteNode
1542
+ node.receiver
1543
+ when Prism::CallNode
1544
+ return nil if node.receiver.nil?
1545
+ return node.receiver if node.attribute_write?
1546
+ return node.receiver if MutationWidening::ARRAY_MUTATORS.include?(node.name) ||
1547
+ MutationWidening::HASH_MUTATORS.include?(node.name)
1548
+
1549
+ nil
1550
+ end
1551
+ end
1552
+
1553
+ # Every lexical-resolution candidate for a bare constant name written at `nesting`, innermost first
1554
+ # — `["A::B", "A"]` + `C` yields `A::B::C`, `A::C`, `C` — so the widener matches whichever form the
1555
+ # write accumulator recorded.
1556
+ #
1557
+ # Issue #708 — this reads Ruby's `Module.nesting`, which the enclosing qualified prefix used to be a
1558
+ # faithful stand-in for. It stopped being one when a rooted header gained the power to RESET that
1559
+ # prefix: a bare `TABLE[:k] = 1` inside `class ::Rooted` in `module Outer` mutates `Outer::TABLE`, and
1560
+ # candidates built from the reset prefix never name it, so the closed shape was never widened and
1561
+ # `Outer::TABLE.empty?` folded to `true` on a table the program fills.
1562
+ def constant_mutation_candidates(base_name, nesting, into)
1563
+ nesting.each { |entry| into << "#{entry}::#{base_name}" }
1564
+ into << base_name
1565
+ end
1566
+
1567
+ def widen_mutated_constants(accumulator, mutated_names)
1568
+ return accumulator if mutated_names.empty?
1569
+
1570
+ widened = accumulator.dup
1571
+ mutated_names.each do |name|
1572
+ existing = widened[name]
1573
+ next if existing.nil? || existing.is_a?(Type::Dynamic)
1574
+
1575
+ widened[name] = Type::Combinator.dynamic(existing)
1576
+ end
1577
+ widened.freeze
1578
+ end
1579
+
1580
+ def widen_mutated_cvars(accumulator, mutated_by_class)
1581
+ return accumulator if mutated_by_class.empty?
1582
+
1583
+ widened = accumulator.dup
1584
+ mutated_by_class.each do |class_name, names|
1585
+ table = widened[class_name]
1586
+ next if table.nil?
1587
+
1588
+ updated = table.dup
1589
+ names.each do |cvar|
1590
+ existing = updated[cvar]
1591
+ next if existing.nil? || existing.is_a?(Type::Dynamic)
1592
+
1593
+ updated[cvar] = Type::Combinator.dynamic(existing)
1594
+ end
1595
+ widened[class_name] = updated.freeze
1596
+ end
1597
+ widened.freeze
1598
+ end
1599
+
1212
1600
  # Issue #352 — folds the project-wide `pre_eval:` constant seed under this file's own table. Returns the
1213
1601
  # per-file table unchanged (same frozen object) when nothing was seeded, so a run without `pre_eval:`
1214
1602
  # constants allocates and compares exactly what it did before.
1603
+ # Issue #644 — merges the per-file constant table OVER the project seed and records, alongside it,
1604
+ # which of those names THIS FILE declares (as last segments). The second table is the other half of
1605
+ # `Scope#published_constant?`: a project-published constant the analysed file also assigns is one its
1606
+ # author can see, so the truthiness rules keep firing on it. Both are seeded here, where the per-file
1607
+ # table is still separable from the project seed it is about to merge over.
1608
+ def seed_constant_tables(seeded_scope, default_scope, in_source_constants, root)
1609
+ merged = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
1610
+ seeded_scope.with_discovery(
1611
+ seeded_scope.discovery.with(in_source_constants: merged,
1612
+ local_constant_names: local_constant_name_set(root, default_scope))
1613
+ )
1614
+ end
1615
+
1616
+ # The QUALIFIED names this file assigns that the project also published, frozen. Read from the same
1617
+ # {#constant_writes_for_file} census the cross-file table is built from, not from the typed per-file
1618
+ # table: the typed walk cannot see a multi-assign / `self::` / operator-write target, so deriving the
1619
+ # exemption from it would be a second source of truth blind to exactly the forms the census exists to
1620
+ # catch. Gated on the project having published anything at all, so a project with no cross-file value
1621
+ # constants pays no extra walk and allocates nothing; the result is filtered to the published names, so
1622
+ # what it holds is bounded by what the exemption can ever be asked about.
1623
+ def local_constant_name_set(root, default_scope)
1624
+ published = default_scope.published_constant_names
1625
+ return Scope::DiscoveryIndex::EMPTY.local_constant_names if published.empty?
1626
+
1627
+ names = constant_writes_for_file(root).keys.select { |name| published.include?(name.split("::").last) }
1628
+ names.empty? ? Scope::DiscoveryIndex::EMPTY.local_constant_names : names.to_set.freeze
1629
+ end
1630
+
1215
1631
  def merge_seeded_constants(seeded, per_file)
1216
1632
  return per_file if seeded.nil? || seeded.empty?
1217
1633
 
1218
1634
  seeded.merge(per_file).freeze
1219
1635
  end
1220
1636
 
1221
- def walk_constant_writes(node, qualified_prefix, default_scope, accumulator)
1637
+ # Issue #705 the block-taking calls that swap `self` for the RECEIVER: Ruby evaluates the block with
1638
+ # the receiver as `self` and leaves `Module.nesting` untouched, so `self::BAR = …` inside one names the
1639
+ # receiver's `BAR` while a bare `BAR = …` in the same block still names the enclosing namespace's.
1640
+ SELF_REBINDING_EVAL_CALLS = %i[class_eval module_eval class_exec module_exec].freeze
1641
+ private_constant :SELF_REBINDING_EVAL_CALLS
1642
+
1643
+ # …and the ones whose block `self` this walk cannot name. An `instance_eval` / `instance_exec` body
1644
+ # evaluates on the RECEIVER OBJECT, which is a class or module only when the receiver happens to be one
1645
+ # (`Klass.instance_eval { self::BAR = 1 }` defines `Klass::BAR`; `obj.instance_eval` raises `TypeError`),
1646
+ # and a `define_method` body on the receiver at call time. A syntactic walk cannot tell those apart, so
1647
+ # the write is declined — gradual for the legal case, and no guess for the rest.
1648
+ OPAQUE_SELF_BLOCK_CALLS = %i[instance_eval instance_exec define_method].freeze
1649
+ private_constant :OPAQUE_SELF_BLOCK_CALLS
1650
+
1651
+ # The sentinel for "`self` was rebound to something no class/module name reaches". A `self::BAR = …`
1652
+ # under it is DECLINED — the same answer a dynamic base takes, for the same reason.
1653
+ OPAQUE_SELF = :__rigor_opaque_self__
1654
+ private_constant :OPAQUE_SELF
1655
+
1656
+ # The owner whose constants the census spells BARE — `Object`'s constants are the top-level ones.
1657
+ OBJECT_OWNER = "Object"
1658
+ private_constant :OBJECT_OWNER
1659
+
1660
+ def walk_constant_writes(node, qualified_prefix, default_scope, accumulator, self_owner = nil)
1222
1661
  return unless node.is_a?(Prism::Node)
1223
1662
 
1224
1663
  case node
1225
1664
  when Prism::ClassNode, Prism::ModuleNode
1226
- name = Source::ConstantPath.qualified_name(node.constant_path)
1227
- if name
1228
- child_prefix = qualified_prefix + [name]
1229
- walk_constant_writes(node.body, child_prefix, default_scope, accumulator) if node.body
1665
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
1666
+ if child_prefix
1667
+ if node.body
1668
+ walk_constant_writes(node.body, child_prefix,
1669
+ scope_entering_declaration(default_scope, node.constant_path),
1670
+ accumulator)
1671
+ end
1230
1672
  return
1231
1673
  end
1232
1674
  when Prism::ConstantWriteNode
1233
- record_constant_write(node, qualified_prefix, default_scope, accumulator, node.name.to_s)
1675
+ record_constant_write(node, qualified_prefix, default_scope, accumulator,
1676
+ qualified_write_name(qualified_prefix, node.name.to_s), self_owner)
1234
1677
  return
1235
1678
  when Prism::ConstantPathWriteNode
1236
- full = Source::ConstantPath.qualified_name(node.target)
1237
- record_constant_write(node, [], default_scope, accumulator, full) if full
1679
+ full = constant_path_write_key(node.target, qualified_prefix, default_scope, self_owner)
1680
+ record_constant_write(node, qualified_prefix, default_scope, accumulator, full, self_owner) if full
1238
1681
  return
1239
1682
  end
1240
1683
 
1684
+ walk_constant_write_children(node, qualified_prefix, default_scope, accumulator, self_owner)
1685
+ end
1686
+
1687
+ # Recursion into `node`'s children, swapping `self` for the ONE child a self-rebinding call evaluates
1688
+ # under a different receiver — its block. Every other child, and every child of a node that rebinds
1689
+ # nothing, keeps the `self` it was reached under.
1690
+ #
1691
+ # The block is identified by its CLASS rather than by identity against `CallNode#block`. These walks
1692
+ # recurse over every node kind, so `node` here is the whole `Prism::Node` union and reading a member
1693
+ # only a call carries off it is exactly the shape Rigor's own check rejects. The test is equivalent:
1694
+ # `rebound` is non-nil only where {#rebound_block_self} already saw a `Prism::BlockNode` in the `block`
1695
+ # field, and no other child of a call can be one — a receiver is an expression, arguments sit under
1696
+ # `ArgumentsNode`, and a `&blk` pass-through is a `BlockArgumentNode`.
1697
+ def walk_constant_write_children(node, qualified_prefix, default_scope, accumulator, self_owner)
1698
+ rebound = rebound_block_self(node, qualified_prefix, default_scope)
1241
1699
  node.rigor_each_child do |child|
1242
- walk_constant_writes(child, qualified_prefix, default_scope, accumulator)
1700
+ owner = rebound && child.is_a?(Prism::BlockNode) ? rebound : self_owner
1701
+ walk_constant_writes(child, qualified_prefix, default_scope, accumulator, owner)
1243
1702
  end
1244
1703
  end
1245
1704
 
1246
- def record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name)
1247
- full = qualified_prefix.empty? ? base_name : "#{qualified_prefix.join('::')}::#{base_name}"
1705
+ # Issue #705 — the class or module `self` names inside `node`'s BLOCK, or nil when `node` opens no
1706
+ # block that rebinds `self` (an ordinary block keeps the `self` it closed over, so a `self::BAR = …`
1707
+ # there is still the enclosing declaration's and every walk below this stays as it was).
1708
+ #
1709
+ # {OPAQUE_SELF} where the new `self` reaches no class or module name: a `class_eval` receiver that is
1710
+ # not a static constant path, an {OPAQUE_SELF_BLOCK_CALLS} body, and the `Class.new { … }` /
1711
+ # `Module.new` / `Struct.new` / `Data.define` block, whose class is named only by a constant write the
1712
+ # walk would have to thread down to it. Declining there costs a resolution the engine never had; naming
1713
+ # the LEXICAL enclosure instead is the guess that fires, because it is the one name Ruby is guaranteed
1714
+ # NOT to have written.
1715
+ def rebound_block_self(node, qualified_prefix, default_scope = nil)
1716
+ return nil unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
1717
+ return OPAQUE_SELF if OPAQUE_SELF_BLOCK_CALLS.include?(node.name) || meta_new_constant_rvalue?(node)
1718
+ return nil unless SELF_REBINDING_EVAL_CALLS.include?(node.name)
1719
+
1720
+ eval_receiver_self(node.receiver, qualified_prefix, default_scope)
1721
+ end
1722
+
1723
+ # The `self` a `Recv.class_eval { … }` block runs under. An implicit or literal `self` receiver leaves
1724
+ # `self` where it was — nil, so nothing about the walk changes. A static constant path resolves the way
1725
+ # a path write's own namespace resolves ({#resolved_write_namespace}); anything else is opaque. A nil
1726
+ # `default_scope` is the publication census, which has no class knowledge to resolve through and keys a
1727
+ # path AS WRITTEN, so the receiver keeps its spelling there too.
1728
+ def eval_receiver_self(receiver, qualified_prefix, default_scope)
1729
+ return nil if receiver.nil? || receiver.is_a?(Prism::SelfNode)
1730
+
1731
+ name = Source::ConstantPath.qualified_name_or_nil(receiver)
1732
+ return OPAQUE_SELF if name.nil?
1733
+ return name if default_scope.nil? || qualified_prefix.empty? || Source::ConstantPath.rooted?(receiver)
1734
+
1735
+ resolved_write_namespace(name, qualified_prefix, default_scope)
1736
+ end
1737
+
1738
+ # Issue #690 — `enclosing_prefix` is the lexical declaration prefix the write SITS UNDER, `full` the
1739
+ # qualified name it WRITES. The two coincide for a bare `BAR = …` and diverge for a `Foo::BAR = …`,
1740
+ # which is why they are separate parameters. One parameter served both roles, so the path form had to
1741
+ # pass it EMPTY to keep the caller-supplied name intact — and that lost the key's qualification and the
1742
+ # rvalue's lexical context together, in the same argument.
1743
+ #
1744
+ # Issue #705 — and the rvalue needs BOTH halves of the context separately as well, because a
1745
+ # `class_eval` block moves one and not the other. `Module.nesting` stays lexical, so a bare `Post` in
1746
+ # `Other.class_eval { … }` inside `module Admin` is still `Admin::Post`; `self` becomes the RECEIVER, so
1747
+ # `self` and an implicit-self call in the same block answer on `Other`. Reading the self type off
1748
+ # `enclosing_prefix` typed `self::REF = self` as `singleton(Admin)` and `self::V = build` through
1749
+ # `Admin.build` — wrong on correct code, and reachable at a reader the moment the key stopped being a
1750
+ # guess. `self_owner` is a String exactly when the walk resolved a rebound `self`.
1751
+ def record_constant_write(node, enclosing_prefix, default_scope, accumulator, full, self_owner = nil)
1752
+ owner = self_owner.is_a?(String) ? self_owner : enclosing_prefix.join("::")
1248
1753
  body_scope = default_scope
1249
- unless qualified_prefix.empty?
1250
- body_scope = body_scope.with_self_type(Type::Combinator.singleton_of(qualified_prefix.join("::")))
1754
+ unless owner.empty?
1755
+ body_scope = census_body_scope(default_scope, enclosing_prefix, Type::Combinator.singleton_of(owner))
1251
1756
  end
1252
1757
  rvalue_type = meta_new_constant_type(node, full) || body_scope.type_of(node.value)
1253
1758
  existing = accumulator[full]
1254
1759
  accumulator[full] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
1255
1760
  end
1256
1761
 
1762
+ # The accumulator key for a `Foo::BAR = …`. Ruby resolves the path's NAMESPACE through the nesting at
1763
+ # the write site, so `Holder::DEFAULT = …` inside `module Admin` names `Admin::Holder::DEFAULT`
1764
+ # whenever `Admin::Holder` is a class or module some source declares. Filing the entry under the
1765
+ # as-written spelling put it where no read of the resolved name reaches it: a read spelled
1766
+ # `Admin::Holder::DEFAULT` answered `Dynamic[top]`, and the in-namespace spelling fell past the
1767
+ # qualified rung of the lexical ladder to the bare one, hit the mis-keyed entry, and got a top-level
1768
+ # constant of the same name — `call.undefined-method` on correct code ([#690](https://github.com/rigortype/rigor/issues/690)).
1769
+ #
1770
+ # Two static forms keep the as-written key because their namespace is not the enclosing one: a write at
1771
+ # the top level, and a rooted `::Foo::BAR`, which names the top level by definition. Re-qualifying a
1772
+ # namespace the code does not name would file the entry under a guess.
1773
+ #
1774
+ # A target that renders no static path is NOT a third such form, and must not be handled by rendering
1775
+ # it leniently. `self::BAR = …` names whatever `self` is at that point — the enclosing declaration in
1776
+ # an ordinary body, the receiver inside a `class_eval` block ([#705](https://github.com/rigortype/rigor/issues/705)),
1777
+ # which is why `self_owner` travels with the walk rather than being read off `qualified_prefix`. Any
1778
+ # other dynamic base (`klass::BAR`) is attributable to no namespace at all and is DECLINED:
1779
+ # `qualified_name`'s lenient render drops the dynamic segment and yields the bare trailing name, so
1780
+ # recording it filed a value under a name the write never touched — and once the rvalue is typed under
1781
+ # the enclosing nesting, that bare key outranks an RBS top-level constant declaration and hands every
1782
+ # reader of the top-level name a receiver Ruby never names there.
1783
+ def constant_path_write_key(target, qualified_prefix, default_scope, self_owner = nil)
1784
+ return Source::ConstantPath.qualified_name(target) if Source::ConstantPath.rooted?(target)
1785
+
1786
+ written = Source::ConstantPath.qualified_name_or_nil(target)
1787
+ return dynamic_base_write_key(target, qualified_prefix, self_owner) if written.nil?
1788
+ return written if qualified_prefix.empty?
1789
+
1790
+ namespace, _, base = written.rpartition("::")
1791
+ return written if namespace.empty?
1792
+
1793
+ "#{resolved_write_namespace(namespace, qualified_prefix, default_scope)}::#{base}"
1794
+ end
1795
+
1796
+ # The key for a path write whose base is not a static constant path: the name `self` carries for a
1797
+ # `self::BAR = …`, and nil — decline to record the write at all — for every other dynamic base.
1798
+ def dynamic_base_write_key(target, qualified_prefix, self_owner = nil)
1799
+ return nil unless target.parent.is_a?(Prism::SelfNode)
1800
+
1801
+ base = target.name&.to_s
1802
+ base && self_write_name(qualified_prefix, self_owner, base)
1803
+ end
1804
+
1805
+ # The qualified name a `self::BAR = …` writes. `self_owner` is nil in the ordinary case — `self` is the
1806
+ # enclosing declaration, so the write takes the key a bare `BAR = …` in the same body takes — a class or
1807
+ # module name where a `class_eval`-family block rebound `self` to one, and {OPAQUE_SELF} where it
1808
+ # rebound `self` to something unnameable, which declines the write (nil) rather than filing it under a
1809
+ # namespace Ruby did not touch ([#705](https://github.com/rigortype/rigor/issues/705)).
1810
+ #
1811
+ # `Object` is the one owner that is not a namespace of its own: its constants ARE the top-level ones, and
1812
+ # every other census key spells those bare. Keying `Object::T` would file the write beside a sibling's
1813
+ # plain `T = 2` instead of on top of it, so the conflict between them goes undetected and one of the two
1814
+ # values publishes — the direction this whole issue is about.
1815
+ def self_write_name(qualified_prefix, self_owner, base)
1816
+ return qualified_write_name(qualified_prefix, base) if self_owner.nil?
1817
+ return nil if self_owner.equal?(OPAQUE_SELF)
1818
+ return base if self_owner == OBJECT_OWNER
1819
+
1820
+ "#{self_owner}::#{base}"
1821
+ end
1822
+
1823
+ # The namespace of a `Foo::BAR = …`, resolved the way a READ of the same spelling resolves it: the
1824
+ # first `<nesting entry>::Foo` that names a known class or module, innermost first, and the as-written
1825
+ # name when none does. Only class/module knowledge participates — a value constant owns no constants —
1826
+ # and the ancestor rung {Reflection.resolve_constant_type} walks is deliberately skipped, so a
1827
+ # namespace no source declares keeps the key it has today rather than moving to a guess.
1828
+ def resolved_write_namespace(namespace, qualified_prefix, scope)
1829
+ census_nesting(scope, qualified_prefix).each do |entry|
1830
+ candidate = "#{entry}::#{namespace}"
1831
+ return candidate if known_namespace?(candidate, scope)
1832
+ end
1833
+ namespace
1834
+ end
1835
+
1836
+ # True when `name` is a class or module some source declares: a project declaration in
1837
+ # `discovered_classes` or an RBS-known class object. The [#528](https://github.com/rigortype/rigor/issues/528)
1838
+ # synthesized namespace prefixes reach that table for THIS FILE only — the runner's cross-file seed
1839
+ # (`discovered_project_index_for_paths`) returns `collect_class_decls`'s raw declarations without the
1840
+ # synthesis its sibling `discovered_classes_for_paths` applies — so a namespace known only through ANOTHER
1841
+ # file's COMPACT declaration (`class Admin::Holder::Inner`) does not move the key, where an explicit
1842
+ # `module Admin; class Holder` there does. Under-resolution, the direction this probe may err in.
1843
+ #
1844
+ # The RBS half needs no dependency edge — the loaded signature set is part of the run fingerprint — but
1845
+ # both project answers do; see {#record_namespace_probe}.
1846
+ def known_namespace?(name, scope)
1847
+ if scope.discovered_classes.key?(name)
1848
+ record_namespace_probe(name, scope, hit: true)
1849
+ return true
1850
+ end
1851
+
1852
+ environment = scope.environment
1853
+ return true if !environment.nil? && !environment.singleton_for_name(name).nil?
1854
+
1855
+ record_namespace_probe(name, scope, hit: false)
1856
+ false
1857
+ end
1858
+
1859
+ # ADR-46 — the key this census files a path write under is a function of ANOTHER file's class
1860
+ # declarations, and no edge the READER records covers it: a reference's negative key is
1861
+ # `class:<last segment>` of the name that failed to resolve, whereas here the write's key moves because
1862
+ # a MIDDLE segment appeared (`Holder::DEFAULT` inside `module Admin` starts naming
1863
+ # `Admin::Holder::DEFAULT` the moment some file declares `Admin::Holder`). Both directions therefore
1864
+ # need their own edge, recorded where the probe happens: a POSITIVE one on the file declaring the
1865
+ # namespace we resolved through, so deleting it moves the key back, and a NEGATIVE `class:` one on
1866
+ # every candidate that missed, so declaring it later moves the key forward. Without them a warm
1867
+ # incremental run serves a cached answer computed against a namespace that has since appeared. Gated on
1868
+ # the recorder, which is off on every ordinary run.
1869
+ def record_namespace_probe(name, scope, hit:)
1870
+ return unless Analysis::DependencyRecorder.active?
1871
+
1872
+ if hit
1873
+ scope.discovered_class_sources[name]&.each { |site| Analysis::DependencyRecorder.read_site(site) }
1874
+ else
1875
+ Analysis::DependencyRecorder.read_missing(:class, name.split("::").last)
1876
+ end
1877
+ end
1878
+
1257
1879
  # Survey item (e): when the rvalue is a recognised `Module.new do ... end` / `Class.new do ... end` /
1258
1880
  # `Struct.new(*sym) do ... end` / `Data.define(*sym) do ... end` form, type the named constant as
1259
1881
  # `Singleton[<full>]` so the discovered-method table registered under `full` becomes reachable through
@@ -1334,9 +1956,8 @@ module Rigor
1334
1956
 
1335
1957
  case node
1336
1958
  when Prism::ClassNode, Prism::ModuleNode
1337
- name = Source::ConstantPath.qualified_name(node.constant_path)
1338
- if name
1339
- child_prefix = qualified_prefix + [name]
1959
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
1960
+ if child_prefix
1340
1961
  record_meta_superclass_members(node, child_prefix, methods_acc) if node.is_a?(Prism::ClassNode)
1341
1962
  if node.body
1342
1963
  walk_methods_and_def_nodes(node.body, child_prefix, false, methods_acc, def_nodes_acc, source_path)
@@ -1354,11 +1975,12 @@ module Rigor
1354
1975
  when Prism::ConstantWriteNode
1355
1976
  if meta_new_block_body(node)
1356
1977
  child_prefix = qualified_prefix + [node.name.to_s]
1978
+ record_meta_members(node.value, child_prefix, methods_acc)
1357
1979
  walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc,
1358
1980
  source_path)
1359
- # No anonymous registration here: the constant IS the name, and `StatementEvaluator` never routes a
1360
- # constant-write rvalue through the block-body narrowing (its scope index still shows `self` as
1361
- # `Dynamic[top]` inside such a body), so the two passes agree on the constant name alone.
1981
+ # No anonymous registration here: the constant IS the name, and `StatementEvaluator#eval_constant_write`
1982
+ # enters the body under it (#590) by asking THIS recognition (`meta_new_block_body`), so the two passes
1983
+ # agree on the constant name alone.
1362
1984
  return
1363
1985
  end
1364
1986
  when Prism::DefNode
@@ -1390,14 +2012,30 @@ module Rigor
1390
2012
  def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
1391
2013
  record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
1392
2014
  record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if ATTR_MACROS.include?(node.name)
2015
+ record_module_attr_methods(node, qualified_prefix, methods_acc) if MODULE_ATTR_MACROS.key?(node.name)
2016
+ record_alias_method_call(node, qualified_prefix, in_singleton_class, methods_acc)
1393
2017
  AnonymousMetaClass.name_for(node, source_path)
1394
2018
  end
1395
2019
 
2020
+ # The `alias_method :new, :old` CallNode twin of {#record_alias_method} (#533): the NEW name joins
2021
+ # the discovered-methods table so calls to it stop reading undefined, and
2022
+ # {#apply_alias_def_nodes}'s map walk gives it the ORIGINAL def node for return inference.
2023
+ def record_alias_method_call(call_node, qualified_prefix, in_singleton_class, accumulator)
2024
+ return if qualified_prefix.empty?
2025
+
2026
+ names = alias_method_call_names(call_node)
2027
+ return if names.nil?
2028
+
2029
+ kind = in_singleton_class ? :singleton : :instance
2030
+ record_method_kind(accumulator, qualified_prefix.join("::"), names.first, kind)
2031
+ end
2032
+
1396
2033
  # #319 — walks a `Class.new do ... end` / `Module.new do ... end` / `Struct.new(*sym) do ... end` /
1397
2034
  # `Data.define(*sym) do ... end` block body as the class body it is at runtime, keyed by the call site's
1398
2035
  # synthetic anonymous `name`; the call's other children (receiver, arguments) keep the enclosing prefix.
1399
2036
  def walk_anonymous_meta_block(call_node, name, qualified_prefix, in_singleton_class, methods_acc,
1400
2037
  def_nodes_acc, source_path)
2038
+ record_meta_members(call_node, [name], methods_acc)
1401
2039
  call_node.rigor_each_child do |child|
1402
2040
  if child.equal?(call_node.block)
1403
2041
  body = call_node.block.body
@@ -1461,6 +2099,7 @@ module Rigor
1461
2099
  return nil unless node.is_a?(Prism::ConstantWriteNode)
1462
2100
 
1463
2101
  rvalue = node.value
2102
+ return nil unless rvalue.is_a?(Prism::CallNode)
1464
2103
  return nil unless data_define_call?(rvalue) ||
1465
2104
  struct_new_call?(rvalue) ||
1466
2105
  module_new_call?(rvalue) ||
@@ -1473,22 +2112,61 @@ module Rigor
1473
2112
  # the subclass that no `def` / `attr_*` declares. Register them in the discovered-methods existence table so an
1474
2113
  # implicit-self read of a member inside the class body is known to exist — both for the existing undefined-method
1475
2114
  # suppression and for the ADR-24 slice-4 self-call recorder, which must treat a synthesized member as an existing
1476
- # method, not an unresolved call. The block-form (`Const = Data.define(:a) do ... end`) is handled by the
1477
- # `ConstantWriteNode` branch's block recursion; its members type `self` as `Object`, out of scope here.
2115
+ # method, not an unresolved call.
1478
2116
  def record_meta_superclass_members(class_node, qualified_prefix, accumulator)
1479
- superclass = class_node.superclass
1480
- return unless data_define_call?(superclass) || struct_new_call?(superclass)
2117
+ record_meta_members(class_node.superclass, qualified_prefix, accumulator, allow_outer_block: false)
2118
+ end
2119
+
2120
+ # The registration itself, shared with the two BLOCK forms — `Const = Struct.new(:a) do … end` from the
2121
+ # `ConstantWriteNode` branch and the anonymous `Struct.new(:a) do … end` from {#walk_anonymous_meta_block}
2122
+ # (#590). Their bodies are entered as class bodies, so a member read inside one dispatches on the struct's
2123
+ # own class and must find the reader there: a member that shadows a `Kernel` private (`lambda`) otherwise
2124
+ # falls through to `Kernel#lambda`, and `lambda.upcase` reports an undefined method on `Proc` — a wrong-type
2125
+ # diagnostic on correct code. No-op for `Class.new` / `Module.new` and for a factory whose members are not
2126
+ # literal Symbols (nothing to register).
2127
+ def record_meta_members(factory_call, qualified_prefix, accumulator, allow_outer_block: true)
2128
+ factory_call = resolve_meta_factory_call(factory_call, allow_outer_block: allow_outer_block)
2129
+ return unless factory_call
1481
2130
 
1482
- members = meta_member_names(superclass)
2131
+ members = meta_member_names(factory_call)
1483
2132
  return if members.empty?
1484
2133
 
1485
2134
  class_name = qualified_prefix.join("::")
1486
2135
  members.each { |member| record_method_kind(accumulator, class_name, member, :instance) }
1487
2136
  end
1488
2137
 
2138
+ # Unwinds nested single-parent `Class.new(...)` calls to a root `Struct.new(...)` / `Data.define(...)`.
2139
+ # A nested wrapper or factory block is declined because its method overrides belong to the intermediate
2140
+ # superclass and cannot be attributed to the outer class's reader-override guard.
2141
+ def resolve_meta_factory_call(call_node, allow_outer_block: true)
2142
+ wrapped = false
2143
+ while class_new_call?(call_node)
2144
+ return nil if intermediate_factory_block?(call_node, wrapped, allow_outer_block)
2145
+
2146
+ arguments = call_node.arguments&.arguments
2147
+ return nil unless arguments&.one?
2148
+
2149
+ call_node = arguments.first
2150
+ wrapped = true
2151
+ end
2152
+ return nil unless meta_factory_call?(call_node)
2153
+ return nil if intermediate_factory_block?(call_node, wrapped, allow_outer_block)
2154
+
2155
+ call_node
2156
+ end
2157
+
2158
+ def intermediate_factory_block?(call_node, wrapped, allow_outer_block)
2159
+ (wrapped || !allow_outer_block) && !call_node.block.nil?
2160
+ end
2161
+
2162
+ def meta_factory_call?(call_node)
2163
+ data_define_call?(call_node) || struct_new_call?(call_node)
2164
+ end
2165
+
1489
2166
  # The Symbol member names of a `Data.define(*Symbol)` / `Struct.new(*Symbol [, keyword_init:])` call. For
1490
- # `Struct.new` the optional leading String name and trailing `keyword_init:` hash are stripped by
1491
- # {#struct_new_positionals}; `Data.define` args are all Symbols already.
2167
+ # `Struct.new` the trailing `keyword_init:` hash is stripped by {#struct_new_positionals}; a leading
2168
+ # String class name is NOT stripped, so `Struct.new("Name", :a) do` registers no members and the body
2169
+ # is entered under the anonymous name (consistent between passes). `Data.define` args are all Symbols.
1492
2170
  def meta_member_names(call_node)
1493
2171
  raw = call_node.arguments&.arguments || []
1494
2172
  symbols = struct_new_call?(call_node) ? (struct_new_positionals(raw) || []) : raw
@@ -1592,9 +2270,9 @@ module Rigor
1592
2270
 
1593
2271
  case node
1594
2272
  when Prism::ClassNode, Prism::ModuleNode
1595
- name = Source::ConstantPath.qualified_name(node.constant_path)
1596
- if name
1597
- walk_singleton_body(node.body, qualified_prefix + [name], false, accumulator) if node.body
2273
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2274
+ if child_prefix
2275
+ walk_singleton_body(node.body, child_prefix, false, accumulator) if node.body
1598
2276
  return
1599
2277
  end
1600
2278
  when Prism::SingletonClassNode
@@ -1621,6 +2299,68 @@ module Rigor
1621
2299
  end
1622
2300
  end
1623
2301
 
2302
+ EMPTY_NESTING = [].freeze
2303
+
2304
+ # The empty qualified prefix. Distinct from {EMPTY_NESTING} only in what it MEANS: the two are handed
2305
+ # to different parameters, and this cycle spent a blocker on a prefix and a nesting sharing a name.
2306
+ EMPTY_PREFIX = [].freeze
2307
+ private_constant :EMPTY_NESTING
2308
+
2309
+ # Issue #681 — `{Prism::DefNode => Module.nesting}` for every `def` the file declares inside a class or
2310
+ # module body, keyed by node IDENTITY so the answer follows the DECLARATION rather than the class a
2311
+ # call happens to dispatch on. `Inference::ExpressionTyper#build_user_method_body_scope` rebuilds a
2312
+ # callee's body scope from the RECEIVER'S TYPE alone when it needs that callee's return, so unlike the
2313
+ # census scopes (#692) there is no prefix in hand to stamp: recording the chain where the declaration is
2314
+ # indexed is what makes the re-walk and the declaration walk agree BY CONSTRUCTION, which is the
2315
+ # argument #685 used to make {Reflection.lexical_nesting_chain} the single owner.
2316
+ #
2317
+ # Keyed on the def node and not on `(class, method)` because an inherited body is re-walked with the
2318
+ # SUBCLASS as receiver: `class Admin::CompactBase` declares `make`, `module Admin; class Child <
2319
+ # CompactBase` calls it, and the bare `Post` inside `make` names `::Post` — the constant the compact
2320
+ # declaration that OWNS the body reaches, not the one the receiver's spelling would suggest.
2321
+ #
2322
+ # A separate descent rather than a leaf of the fused methods/def-nodes walk: it needs the CHAIN
2323
+ # threaded, which the fused walk does not carry (a singleton-class body and a `Class.new` block body
2324
+ # both push a qualified prefix while pushing no `Module.nesting` entry), and threading a second value
2325
+ # through that walk and its anonymous-block twin exceeds their parameter budget. It stops at every
2326
+ # `Prism::DefNode`, so a def-dense file pays only the declaration spine.
2327
+ def build_def_nestings(root)
2328
+ accumulator = {}.compare_by_identity
2329
+ walk_def_nestings(root, EMPTY_NESTING, accumulator)
2330
+ accumulator.freeze
2331
+ end
2332
+
2333
+ # Records `nesting` for every `def` reachable from `node`. Only a `class` / `module` keyword pushes an
2334
+ # entry (qualified against the entry already on top, so a compact `class Admin::X` contributes ONE);
2335
+ # every other body — a singleton class, a `Class.new` / `Module.new` block, any other block — inherits
2336
+ # the chain unchanged, because Ruby pushes no cref for them.
2337
+ #
2338
+ # Issue #716 — a top-level `def` records the EMPTY chain, and that is a recorded answer rather than the
2339
+ # absence of one. Ruby's `Module.nesting` in a top-level body IS `[]`, so `def helper = Post.new` names
2340
+ # `::Post` wherever it is later called from; recording nothing left
2341
+ # {Reflection.lexical_nesting_chain} peeling the CALLER's qualified name, which answered `Admin::Post`
2342
+ # for a call made from inside `module Admin`. The table is keyed by node identity and populated only by
2343
+ # this walk, so "present with `[]`" (walked, and top level) stays distinguishable from "absent" (no
2344
+ # declaration walk built this scope — a plugin-constructed scope, an anonymous `Class.new` re-entered
2345
+ # through `Scope#evaluate`), which keeps the peel where it is still the only available answer.
2346
+ def walk_def_nestings(node, nesting, accumulator)
2347
+ return unless node.is_a?(Prism::Node)
2348
+
2349
+ case node
2350
+ when Prism::ClassNode, Prism::ModuleNode
2351
+ if node.body
2352
+ walk_def_nestings(node.body, Source::ConstantPath.pushed_nesting(nesting, node.constant_path),
2353
+ accumulator)
2354
+ end
2355
+ return
2356
+ when Prism::DefNode
2357
+ accumulator[node] = nesting unless nesting.nil?
2358
+ return
2359
+ end
2360
+
2361
+ node.rigor_each_child { |child| walk_def_nestings(child, nesting, accumulator) }
2362
+ end
2363
+
1624
2364
  # Walks a class/module/singleton-class body's direct statements in source order, threading the
1625
2365
  # bare-`module_function` toggle: once a bare `module_function` is seen, every subsequent `def` in the body
1626
2366
  # registers as a singleton method. Nested classes/modules/defs and `module_function :a, :b` named forms recurse /
@@ -1700,41 +2440,153 @@ module Rigor
1700
2440
  # ADR-24 slice 2 — per-class table mapping a fully qualified user class to its superclass name AS WRITTEN at the
1701
2441
  # `class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(...)` and other
1702
2442
  # non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call
1703
- # site against the subclass's lexical nesting — see `ExpressionTyper#resolve_ancestor_class_name`.
1704
- def build_discovered_superclasses(root, source_path = nil)
1705
- accumulator = {}
2443
+ # site against the nesting the declaration's HEADER sits in — see {Scope#ancestor_name_candidates}, which reads
2444
+ # the second table this walk builds.
2445
+ #
2446
+ # Issue #682 — `header_nestings` maps the same qualified class name to that nesting: `Module.nesting` where the
2447
+ # `class` / `module` KEYWORD is written, which is the body's chain minus the declaration's own entry. It is
2448
+ # recorded here, with the walk's prefix in hand, because it is unrecoverable afterwards: `class Admin::Widget`
2449
+ # and `module Admin; class Widget` render the identical `"Admin::Widget"`, and an ancestor name is resolved in
2450
+ # the enclosing cref — `[]` for the first spelling, `["Admin"]` for the second. A class reopened under two
2451
+ # spellings keeps the LAST one walked, matching how `superclasses` itself merges.
2452
+ #
2453
+ # @return [Array(Hash{String=>String}, Hash{String=>Array[String]})] `[superclasses, header_nestings]`
2454
+ def build_superclass_tables(root, source_path = nil)
2455
+ accumulator = { superclasses: {}, header_nestings: {} }
1706
2456
  walk_class_superclasses(root, [], accumulator, source_path)
1707
- accumulator.freeze
2457
+ [accumulator[:superclasses].freeze, accumulator[:header_nestings].freeze]
1708
2458
  end
1709
2459
 
1710
- def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil)
2460
+ def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil, nesting = EMPTY_NESTING)
1711
2461
  return unless node.is_a?(Prism::Node)
1712
2462
 
1713
2463
  case node
1714
2464
  when Prism::CallNode
1715
- record_anonymous_meta_superclass(node, accumulator, source_path)
1716
- when Prism::ClassNode
1717
- name = Source::ConstantPath.qualified_name(node.constant_path)
1718
- if name
1719
- full = (qualified_prefix + [name]).join("::")
1720
- superclass = node.superclass && Source::ConstantPath.qualified_name(node.superclass)
1721
- accumulator[full] = superclass if superclass
1722
- walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
1723
- return
1724
- end
1725
- when Prism::ModuleNode
1726
- name = Source::ConstantPath.qualified_name(node.constant_path)
1727
- if name
1728
- walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
2465
+ record_anonymous_meta_superclass(node, accumulator[:superclasses], source_path)
2466
+ when Prism::ClassNode, Prism::ModuleNode
2467
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2468
+ if child_prefix
2469
+ record_declaration_ancestry(node, nesting, child_prefix, accumulator)
2470
+ if node.body
2471
+ walk_class_superclasses(node.body, child_prefix, accumulator, nil,
2472
+ Source::ConstantPath.pushed_nesting(nesting, node.constant_path) ||
2473
+ nesting)
2474
+ end
1729
2475
  return
1730
2476
  end
1731
2477
  end
1732
2478
 
1733
2479
  node.rigor_each_child do |child|
1734
- walk_class_superclasses(child, qualified_prefix, accumulator, source_path)
2480
+ walk_class_superclasses(child, qualified_prefix, accumulator, source_path, nesting)
1735
2481
  end
1736
2482
  end
1737
2483
 
2484
+ # One declaration's two ancestry facts: the as-written superclass name (classes only), and the
2485
+ # `Module.nesting` its header is written in. `nesting` is the chain in force OUTSIDE the header — the
2486
+ # header is evaluated before the body is entered, so the declaration's own entry is not on the ladder
2487
+ # its superclass name walks — which is why the walk pushes only when it recurses into the body. The
2488
+ # chain is threaded rather than derived from the enclosing prefix because a rooted header resets that
2489
+ # prefix without resetting Ruby's nesting ([#708](https://github.com/rigortype/rigor/issues/708)).
2490
+ def record_declaration_ancestry(node, nesting, child_prefix, accumulator)
2491
+ full = child_prefix.join("::")
2492
+ add_header_nesting(accumulator[:header_nestings], full, nesting) if declares_ancestor_name?(node)
2493
+ return unless node.is_a?(Prism::ClassNode)
2494
+
2495
+ accumulator[:superclasses][full] = recorded_ancestor_name(node.superclass) if node.superclass
2496
+ end
2497
+
2498
+ # Issue #722 residue 1 / #637 — the recorded ancestor name, carrying the ROOTED marker when the site
2499
+ # wrote one. `Source::ConstantPath.qualified_name` renders segments only, so `class Rooted < ::Base`
2500
+ # recorded the same `"Base"` a bare `< Base` does, and the resolver then walked the enclosing nesting
2501
+ # and answered `A::Base` — a class Ruby never looks at, on a spelling whose whole point is that it
2502
+ # does not.
2503
+ #
2504
+ # The marker rides the existing table's VALUE rather than a new table. It is still a String, so the
2505
+ # per-file index, the cross-file fold and the ADR-85 seed bundle carry it unchanged; the three
2506
+ # resolvers that read it strip and anchor. `IncrementalSnapshot::SCHEMA` is bumped anyway, because an
2507
+ # older blob's un-rooted value is indistinguishable from "not rooted" and a warm run would keep the
2508
+ # pre-fix answer for every unchanged file.
2509
+ def recorded_ancestor_name(node)
2510
+ name = Source::ConstantPath.qualified_name(node)
2511
+ return name if name.nil? || !Source::ConstantPath.rooted?(node)
2512
+
2513
+ "::#{name}"
2514
+ end
2515
+
2516
+ # Whether this declaration SITE writes an ancestor name — a superclass in its header, or a mixin call
2517
+ # in its own body. Only such a site contributes a header nesting.
2518
+ #
2519
+ # Issue #708's review: a per-CLASS union of every site's chain is wrong for a rooted reopen inside
2520
+ # another namespace. `class ::Foo; def extra = 1; end` written in `module Outer` contributed `["Outer"]`
2521
+ # to `Foo`, and the ancestor rung then resolved `Foo`'s superclass `Base` as `Outer::Base` — a class
2522
+ # Ruby never looks at, ahead of the right one, on a site that named no ancestor at all. A site that
2523
+ # writes no ancestor name has no ancestor for its cref to govern, so it has nothing to say here.
2524
+ #
2525
+ # This keeps what the union was FOR. It was added because last-writer-wins let one rails TEST file
2526
+ # reopening `ActiveRecord::Relation` compactly beat the library declaration, costing the class all nine
2527
+ # of its `include`d modules; that reopen writes no ancestor name either, so it now contributes nothing
2528
+ # rather than winning — the protection comes from the recording rule instead of from a merge rule.
2529
+ # Two sites that BOTH write ancestor names still union, which is the conservative reading of an
2530
+ # ambiguity one table cannot represent.
2531
+ #
2532
+ # The mixin scan counts only a call whose `self` is the declaration itself, so it stops at every
2533
+ # construct that rebinds `self`: a nested `class` / `module` (that body's calls belong to the nested
2534
+ # class), a `class << self` body, and a block `#rebound_block_self` classifies as rebinding — the
2535
+ # `Class.new` / `Module.new` / `Struct.new` / `Data.define` and `class_eval` family. An `include`
2536
+ # written in any of those attaches to something other than this class, so treating it as this site's
2537
+ # ancestor name reinstates exactly the defect above with a different spelling.
2538
+ #
2539
+ # It stays structural rather than exhaustive: `self.include M` and `send(:include, M)` record nothing
2540
+ # and fall back to the peel, which is this walk's pre-#682 answer and never a new firing — and the
2541
+ # includes walk misses those two as well, so the class's ancestry agrees. For the REBINDING shapes it
2542
+ # does not: `walk_class_includes` still charges an `include` written in a `class << self` body or a
2543
+ # `Class.new` block to the enclosing class, where Ruby attaches it elsewhere. This scan is right and
2544
+ # that one is wrong; the divergence is [#728](https://github.com/rigortype/rigor/issues/728).
2545
+ def declares_ancestor_name?(node)
2546
+ return true if node.is_a?(Prism::ClassNode) && node.superclass
2547
+
2548
+ body = node.body
2549
+ body ? mixin_call_in_body?(body) : false
2550
+ end
2551
+
2552
+ def mixin_call_in_body?(node)
2553
+ return false unless node.is_a?(Prism::Node)
2554
+ return false if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
2555
+ node.is_a?(Prism::SingletonClassNode)
2556
+ return true if node.is_a?(Prism::CallNode) && node.receiver.nil? && MIXIN_CALL_NAMES.include?(node.name)
2557
+
2558
+ rebinds = rebound_block_self(node, EMPTY_PREFIX)
2559
+ node.rigor_each_child do |child|
2560
+ next if rebinds && child.is_a?(Prism::BlockNode)
2561
+
2562
+ return true if mixin_call_in_body?(child)
2563
+ end
2564
+ false
2565
+ end
2566
+
2567
+ # Adds one declaration site's header nesting to the per-class table, UNIONING it with what a
2568
+ # previous site recorded, most-qualified first. A class reopened under two spellings has two header
2569
+ # nestings while the ancestor names its sites write collapse into ONE table, so taking either site's
2570
+ # chain alone drops a candidate the other one had — and dropping a candidate drops an ancestor edge,
2571
+ # the false-positive direction. Rails is the corpus case: `activerecord` declares
2572
+ # `ActiveRecord::Relation` inside `module ActiveRecord` and one test file reopens it as the compact
2573
+ # `class ActiveRecord::Relation`, so last-writer-wins cost the library class nine included modules.
2574
+ # The union is the conservative reading of an ambiguity the per-class table cannot represent: it
2575
+ # degrades such a class to the pre-fix candidate list rather than to a shorter one.
2576
+ def add_header_nesting(table, name, entries)
2577
+ existing = table[name]
2578
+ return table[name] = entries.freeze if existing.nil?
2579
+ return if entries.all? { |entry| existing.include?(entry) }
2580
+
2581
+ table[name] = (existing | entries).sort_by { |entry| [-entry.split("::").size, entry] }.freeze
2582
+ end
2583
+
2584
+ # {#add_header_nesting} over a whole table, for the per-file and cross-file merges.
2585
+ def merge_header_nestings(target, incoming)
2586
+ incoming.each { |name, entries| add_header_nesting(target, name, entries) }
2587
+ target
2588
+ end
2589
+
1738
2590
  # #319 — `Class.new(Parent) do ... end` names its superclass in the first positional. Recording it under the
1739
2591
  # call site's anonymous name keeps `Parent`'s surface reachable from the block body (whose `self_type` is now
1740
2592
  # `Singleton[<anonymous>]`) and from an instance of the resulting class, so giving the anonymous class an
@@ -1745,6 +2597,11 @@ module Rigor
1745
2597
  arg = call_node.arguments&.arguments&.first
1746
2598
  return if arg.nil?
1747
2599
 
2600
+ # Issue #722 residue 1 — deliberately NOT marked rooted here, unlike the `class X < ::Parent`
2601
+ # header. Marking it changed nothing observable: `Made = Class.new(::Base)` inside a namespace that
2602
+ # shadows `Base` still resolved the shadow, so the anonymous class's ancestry is reached by a path
2603
+ # that does not read this value, and shipping a marker no resolver honours would be a change with
2604
+ # no gate. The residue stays on #722 with that finding attached.
1748
2605
  superclass = Source::ConstantPath.qualified_name(arg)
1749
2606
  return if superclass.nil?
1750
2607
 
@@ -1768,16 +2625,18 @@ module Rigor
1768
2625
 
1769
2626
  case node
1770
2627
  when Prism::ClassNode
1771
- name = Source::ConstantPath.qualified_name(node.constant_path)
1772
- if name
1773
- record_data_member_layout(accumulator, qualified_prefix + [name], node.superclass)
1774
- walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
2628
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2629
+ if child_prefix
2630
+ record_data_member_layout(
2631
+ accumulator, child_prefix, node.superclass, allow_outer_block: false
2632
+ )
2633
+ walk_data_member_layouts(node.body, child_prefix, accumulator) if node.body
1775
2634
  return
1776
2635
  end
1777
2636
  when Prism::ModuleNode
1778
- name = Source::ConstantPath.qualified_name(node.constant_path)
1779
- if name
1780
- walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
2637
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2638
+ if child_prefix
2639
+ walk_data_member_layouts(node.body, child_prefix, accumulator) if node.body
1781
2640
  return
1782
2641
  end
1783
2642
  when Prism::ConstantWriteNode
@@ -1791,8 +2650,9 @@ module Rigor
1791
2650
 
1792
2651
  # Records `qualified -> [members]` when `expr` is a `Data.define(*Symbol)` call with at least one literal-Symbol
1793
2652
  # member.
1794
- def record_data_member_layout(accumulator, qualified_parts, expr)
1795
- return unless data_define_call?(expr)
2653
+ def record_data_member_layout(accumulator, qualified_parts, expr, allow_outer_block: true)
2654
+ expr = resolve_meta_factory_call(expr, allow_outer_block: allow_outer_block)
2655
+ return unless expr && data_define_call?(expr)
1796
2656
 
1797
2657
  members = meta_member_names(expr)
1798
2658
  return if members.empty?
@@ -1815,16 +2675,18 @@ module Rigor
1815
2675
 
1816
2676
  case node
1817
2677
  when Prism::ClassNode
1818
- name = Source::ConstantPath.qualified_name(node.constant_path)
1819
- if name
1820
- record_struct_member_layout(accumulator, qualified_prefix + [name], node.superclass)
1821
- walk_struct_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
2678
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2679
+ if child_prefix
2680
+ record_struct_member_layout(
2681
+ accumulator, child_prefix, node.superclass, allow_outer_block: false
2682
+ )
2683
+ walk_struct_member_layouts(node.body, child_prefix, accumulator) if node.body
1822
2684
  return
1823
2685
  end
1824
2686
  when Prism::ModuleNode
1825
- name = Source::ConstantPath.qualified_name(node.constant_path)
1826
- if name
1827
- walk_struct_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
2687
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2688
+ if child_prefix
2689
+ walk_struct_member_layouts(node.body, child_prefix, accumulator) if node.body
1828
2690
  return
1829
2691
  end
1830
2692
  when Prism::ConstantWriteNode
@@ -1838,8 +2700,9 @@ module Rigor
1838
2700
 
1839
2701
  # Records `qualified -> { members:, keyword_init: }` when `expr` is a `Struct.new(*Symbol [, keyword_init:
1840
2702
  # <bool>])` call with at least one literal-Symbol member.
1841
- def record_struct_member_layout(accumulator, qualified_parts, expr)
1842
- return unless struct_new_call?(expr)
2703
+ def record_struct_member_layout(accumulator, qualified_parts, expr, allow_outer_block: true)
2704
+ expr = resolve_meta_factory_call(expr, allow_outer_block: allow_outer_block)
2705
+ return unless expr && struct_new_call?(expr)
1843
2706
 
1844
2707
  members = meta_member_names(expr)
1845
2708
  return if members.empty?
@@ -1882,14 +2745,21 @@ module Rigor
1882
2745
 
1883
2746
  case node
1884
2747
  when Prism::ClassNode, Prism::ModuleNode
1885
- name = Source::ConstantPath.qualified_name(node.constant_path)
1886
- if name
1887
- full = (qualified_prefix + [name]).join("::")
1888
- walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body
2748
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2749
+ if child_prefix
2750
+ full = child_prefix.join("::")
2751
+ walk_class_includes(node.body, child_prefix, full, accumulator) if node.body
1889
2752
  return
1890
2753
  end
2754
+ when Prism::SingletonClassNode
2755
+ # Issue #728 — `class << self; include M; end` mixes M into the SINGLETON: it contributes class
2756
+ # methods, not the instance surface this table feeds. Descending with no owner keeps any nested
2757
+ # declaration walked (a `ClassNode` child sets its own owner) while the mixin calls in the
2758
+ # singleton body record nothing, which is what `extends` is for.
2759
+ current_class = nil
1891
2760
  when Prism::CallNode
1892
2761
  record_mixin_call(node, current_class, accumulator)
2762
+ return walk_mixin_call_children(node, qualified_prefix, current_class, accumulator)
1893
2763
  end
1894
2764
 
1895
2765
  node.rigor_each_child do |child|
@@ -1897,6 +2767,30 @@ module Rigor
1897
2767
  end
1898
2768
  end
1899
2769
 
2770
+ # Issue #728 — a block that REBINDS `self` owns the mixin calls written in it, and this walk used to
2771
+ # hand them to the lexically enclosing declaration. `Thing = Class.new { include Taggable }` inside
2772
+ # `class Outer` registered `Taggable` on `Outer`, so `Outer.new.tag` typed `:tagged` where MRI raises
2773
+ # `NoMethodError` — a wrong answer, not a wider one. The classification is
2774
+ # {#rebound_block_self}, the same one #721 gave its own mixin scan.
2775
+ #
2776
+ # `Recv.class_eval { include M }` resolves to `Recv` and now records there, which it never did before
2777
+ # (the block's owner was the lexical enclosure, so at the top level the include was simply dropped).
2778
+ # An OPAQUE rebinding — `Class.new` / `Module.new` / a non-constant `class_eval` receiver — records
2779
+ # nothing: the class exists but this walk cannot name it, and naming the lexical enclosure instead is
2780
+ # the one answer Ruby is guaranteed not to have written.
2781
+ def walk_mixin_call_children(node, qualified_prefix, current_class, accumulator)
2782
+ rebound = rebound_block_self(node, qualified_prefix)
2783
+ node.rigor_each_child do |child|
2784
+ owner =
2785
+ if rebound && child.is_a?(Prism::BlockNode)
2786
+ rebound == OPAQUE_SELF ? nil : rebound
2787
+ else
2788
+ current_class
2789
+ end
2790
+ walk_class_includes(child, qualified_prefix, owner, accumulator)
2791
+ end
2792
+ end
2793
+
1900
2794
  def record_mixin_call(node, current_class, accumulator)
1901
2795
  return unless current_class && node.receiver.nil?
1902
2796
  return unless MIXIN_CALL_NAMES.include?(node.name)
@@ -1907,6 +2801,78 @@ module Rigor
1907
2801
  end
1908
2802
  end
1909
2803
 
2804
+ # Issue #526 — `extend M` / `extend self` / bare `module_function`, the singleton-side siblings of
2805
+ # {#build_discovered_includes}. `extend self` and the bare `module_function` toggle both map the
2806
+ # module's own instance defs onto its singleton (recorded as extending ITSELF); `module_function`
2807
+ # with symbol arguments already resolves through the existing recording, and the toggle's
2808
+ # order-sensitivity (only SUBSEQUENT defs become module functions) is deliberately over-approximated
2809
+ # — the extra names only suppress `undefined-method` and enable inference on calls that raise at
2810
+ # runtime, both the ADR-5-safe direction.
2811
+ def build_discovered_extends(root)
2812
+ accumulator = {}
2813
+ walk_class_extends(root, [], nil, accumulator)
2814
+ accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
2815
+ end
2816
+
2817
+ def walk_class_extends(node, qualified_prefix, current_class, accumulator)
2818
+ return unless node.is_a?(Prism::Node)
2819
+
2820
+ case node
2821
+ when Prism::ClassNode, Prism::ModuleNode
2822
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2823
+ if child_prefix
2824
+ full = child_prefix.join("::")
2825
+ walk_class_extends(node.body, child_prefix, full, accumulator) if node.body
2826
+ return
2827
+ end
2828
+ when Prism::CallNode
2829
+ record_extend_call(node, current_class, accumulator)
2830
+ end
2831
+
2832
+ node.rigor_each_child do |child|
2833
+ walk_class_extends(child, qualified_prefix, current_class, accumulator)
2834
+ end
2835
+ end
2836
+
2837
+ def record_extend_call(node, current_class, accumulator)
2838
+ return unless current_class && node.receiver.nil?
2839
+
2840
+ case node.name
2841
+ when :extend then record_extend_targets(node, current_class, accumulator)
2842
+ when :module_function
2843
+ (accumulator[current_class] ||= []) << current_class if node.arguments.nil?
2844
+ end
2845
+ end
2846
+
2847
+ def record_extend_targets(node, current_class, accumulator)
2848
+ node.arguments&.arguments&.each do |arg|
2849
+ target = arg.is_a?(Prism::SelfNode) ? current_class : Source::ConstantPath.qualified_name(arg)
2850
+ (accumulator[current_class] ||= []) << target if target
2851
+ end
2852
+ end
2853
+
2854
+ # The materialization half of #526: for every `C extends M`, M's INSTANCE defs become C's
2855
+ # SINGLETON defs — existence (so `C.helper` stops firing undefined-method) and def nodes (so
2856
+ # call-site return inference runs with `self = Singleton[C]`, which is what Ruby binds). A name C
2857
+ # already defines on its own singleton wins (`||=`); an extend target with no discovered defs
2858
+ # contributes nothing (RBS-module extends stay on the dispatch tiers).
2859
+ def fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, methods)
2860
+ extends.each do |class_name, mods|
2861
+ mods.each do |mod_name|
2862
+ source_defs = def_nodes[mod_name]
2863
+ next if source_defs.nil?
2864
+
2865
+ # An inner table inherited unchanged from a frozen seed must be thawed before the fold writes.
2866
+ target = singleton_def_nodes[class_name]
2867
+ target = singleton_def_nodes[class_name] = (target ? target.dup : {}) if target.nil? || target.frozen?
2868
+ source_defs.each do |method_name, def_node|
2869
+ target[method_name] ||= def_node
2870
+ record_method_kind(methods, class_name, method_name, :singleton)
2871
+ end
2872
+ end
2873
+ end
2874
+ end
2875
+
1910
2876
  VISIBILITY_MODIFIERS = %i[public private protected].freeze
1911
2877
 
1912
2878
  # v0.1.2 — per-class method-visibility table for the `def.method-visibility-mismatch` CheckRule.
@@ -1933,15 +2899,14 @@ module Rigor
1933
2899
  accumulator.transform_values(&:freeze).freeze
1934
2900
  end
1935
2901
 
1936
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
2902
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength
1937
2903
  def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
1938
2904
  return current_visibility unless node.is_a?(Prism::Node)
1939
2905
 
1940
2906
  case node
1941
2907
  when Prism::ClassNode, Prism::ModuleNode
1942
- name = Source::ConstantPath.qualified_name(node.constant_path)
1943
- if name
1944
- child_prefix = qualified_prefix + [name]
2908
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
2909
+ if child_prefix
1945
2910
  walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
1946
2911
  return current_visibility
1947
2912
  end
@@ -1982,7 +2947,6 @@ module Rigor
1982
2947
  end
1983
2948
  current_visibility
1984
2949
  end
1985
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1986
2950
 
1987
2951
  def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
1988
2952
  return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
@@ -2071,9 +3035,9 @@ module Rigor
2071
3035
 
2072
3036
  case node
2073
3037
  when Prism::ClassNode, Prism::ModuleNode
2074
- name = Source::ConstantPath.qualified_name(node.constant_path)
2075
- if name
2076
- collect_class_alias_map(node.body, qualified_prefix + [name], accumulator) if node.body
3038
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
3039
+ if child_prefix
3040
+ collect_class_alias_map(node.body, child_prefix, accumulator) if node.body
2077
3041
  return accumulator
2078
3042
  end
2079
3043
  when Prism::SingletonClassNode
@@ -2081,12 +3045,36 @@ module Rigor
2081
3045
  when Prism::AliasMethodNode
2082
3046
  record_alias_map_entry(node, qualified_prefix, accumulator)
2083
3047
  return accumulator
3048
+ when Prism::CallNode
3049
+ # `alias_method :new, :old` — the CallNode twin of the `alias` keyword (#533; liquid's i18n
3050
+ # `t` alias was the corpus case). Recorded, then the walk continues: unlike AliasMethodNode a
3051
+ # call's children can carry further class bodies (`Class.new do … end`).
3052
+ names = alias_method_call_names(node)
3053
+ if names && !qualified_prefix.empty?
3054
+ (accumulator[qualified_prefix.join("::")] ||= {})[names.first] =
3055
+ names.last
3056
+ end
2084
3057
  end
2085
3058
 
2086
3059
  node.rigor_each_child { |child| collect_class_alias_map(child, qualified_prefix, accumulator) }
2087
3060
  accumulator
2088
3061
  end
2089
3062
 
3063
+ # `[new_name, old_name]` for an implicit-self `alias_method` call with two literal symbol /
3064
+ # string arguments, or nil. A variable-named alias is runtime data and stays unrecorded.
3065
+ def alias_method_call_names(call_node)
3066
+ return nil unless call_node.name == :alias_method && call_node.receiver.nil?
3067
+
3068
+ args = call_node.arguments&.arguments
3069
+ return nil unless args && args.size == 2
3070
+
3071
+ new_name = literal_method_name(args[0])
3072
+ old_name = literal_method_name(args[1])
3073
+ return nil if new_name.nil? || old_name.nil?
3074
+
3075
+ [new_name, old_name]
3076
+ end
3077
+
2090
3078
  def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
2091
3079
  return if qualified_prefix.empty?
2092
3080
  return unless alias_node.new_name.is_a?(Prism::SymbolNode) && alias_node.old_name.is_a?(Prism::SymbolNode)
@@ -2134,6 +3122,53 @@ module Rigor
2134
3122
  end
2135
3123
  end
2136
3124
 
3125
+ # Issue #736 — the module-level accessor macros, which introduce methods on BOTH sides of the class.
3126
+ # ActiveSupport defines them on `Module`, so every class and module body can use them, and Rails codebases
3127
+ # do: `mattr_accessor` / `cattr_accessor` (an alias of it) and `class_attribute` account for 92 false
3128
+ # `call.undefined-method` on redmine the moment any `sig/` declares the class — invisible before that,
3129
+ # because an undeclared receiver is not a receiver the rule can speak about.
3130
+ #
3131
+ # The value is `[reader, writer, predicate]`. `class_attribute` also defines `x?` on both sides.
3132
+ #
3133
+ # Recognising ActiveSupport's spellings in the engine's own walk is a deliberate exception to "steer
3134
+ # metaprogramming toward the plugin API": the consumer that has to see these is the cross-file
3135
+ # `discovered_methods` table (`finalize_def_index` keeps accessor-introduced names there precisely
3136
+ # because they are not monkey-patches), and no plugin surface reaches it — the ADR-16 synthetic-method
3137
+ # tier feeds the DISPATCHER, which `Analysis::CheckRules` does not consult. A name table is the smallest
3138
+ # thing that works; a plugin-supplied one can replace it whenever that surface exists.
3139
+ MODULE_ATTR_MACROS = {
3140
+ mattr_reader: [true, false, false], mattr_writer: [false, true, false],
3141
+ mattr_accessor: [true, true, false], cattr_reader: [true, false, false],
3142
+ cattr_writer: [false, true, false], cattr_accessor: [true, true, false],
3143
+ class_attribute: [true, true, true]
3144
+ }.freeze
3145
+
3146
+ # Both kinds, unconditionally. `instance_accessor: false` (and its `instance_reader:` /
3147
+ # `instance_writer:` / `instance_predicate:` siblings) narrows the real surface, and honouring them here
3148
+ # would buy a missed detection on a shape nobody writes by accident — this table's only consumer is a
3149
+ # SUPPRESSION, where over-recording costs a diagnostic that would have fired on working code anyway.
3150
+ def record_module_attr_methods(call_node, qualified_prefix, accumulator)
3151
+ return if qualified_prefix.empty?
3152
+ return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
3153
+ return if call_node.arguments.nil?
3154
+
3155
+ reader, writer, predicate = MODULE_ATTR_MACROS.fetch(call_node.name)
3156
+ class_name = qualified_prefix.join("::")
3157
+ call_node.arguments.arguments.each do |arg|
3158
+ base = literal_method_name(arg)
3159
+ next if base.nil?
3160
+
3161
+ record_both_kinds(accumulator, class_name, base) if reader
3162
+ record_both_kinds(accumulator, class_name, :"#{base}=") if writer
3163
+ record_both_kinds(accumulator, class_name, :"#{base}?") if predicate
3164
+ end
3165
+ end
3166
+
3167
+ def record_both_kinds(accumulator, class_name, method_name)
3168
+ record_method_kind(accumulator, class_name, method_name, :instance)
3169
+ record_method_kind(accumulator, class_name, method_name, :singleton)
3170
+ end
3171
+
2137
3172
  def literal_method_name(node)
2138
3173
  return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
2139
3174
 
@@ -2169,7 +3204,7 @@ module Rigor
2169
3204
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
2170
3205
  next
2171
3206
  end
2172
- accumulator.freeze
3207
+ synthesize_namespace_prefixes(accumulator).freeze
2173
3208
  end
2174
3209
 
2175
3210
  # ADR-24 slice 2 — cross-file companion to `discovered_classes_for_paths`. Walks every project file once and
@@ -2249,12 +3284,30 @@ module Rigor
2249
3284
  def declaration_signature(file_index)
2250
3285
  parts = []
2251
3286
  append_ancestry_signature(parts, file_index)
3287
+ append_constant_signature(parts, file_index)
2252
3288
  append_declaration_tables(parts, file_index)
2253
3289
  append_def_signatures(parts, file_index[:def_nodes], "#")
2254
3290
  append_def_signatures(parts, file_index[:singleton_def_nodes], ".")
2255
3291
  Digest::SHA256.hexdigest(parts.join("\x00"))
2256
3292
  end
2257
3293
 
3294
+ # Issue #644 — the cross-file VALUE-constant surface. Load-bearing for soundness, not precision: a
3295
+ # file's published constants are a declaration-level fact its file-level dependents consume, so
3296
+ # `FOO = :sym` becoming `FOO = :other` MUST move this signature or the reader would be declaration-
3297
+ # stable, dropped from the closure, and served the pre-edit `:sym`. Both halves are here: the write
3298
+ # NAMES (an added / removed / renamed assignment) and the published VALUES (a same-name value edit).
3299
+ def append_constant_signature(parts, file_index)
3300
+ parts.concat((file_index[:constant_writes] || {}).sort.map do |name, by_path|
3301
+ "k:#{name}=#{constant_descriptor_signature(by_path.values.first)}"
3302
+ end)
3303
+ end
3304
+
3305
+ # `[literal]` renders its value; an unpublishable write renders as `?`. Both halves matter: a write
3306
+ # appearing or vanishing moves the signature, and so does the same name's value changing.
3307
+ def constant_descriptor_signature(descriptor)
3308
+ descriptor.is_a?(Array) ? descriptor.first.inspect : "?"
3309
+ end
3310
+
2258
3311
  # The class-declaration + ancestry + member-layout surface of the declaration signature (declared class
2259
3312
  # names, superclass / include ancestry, Data/Struct member layouts). Kept out of
2260
3313
  # {#declaration_signature} to hold its ABC budget.
@@ -2466,7 +3519,23 @@ module Rigor
2466
3519
  # bundle's carries {DefHandle}s. The merges never deref the value, so both fold identically.
2467
3520
  def fold_file_index(acc, file_index)
2468
3521
  fold_def_tables(acc, file_index)
3522
+ # Issue #681 — a re-walked file contributes live nodes and their chains together. A file restored from
3523
+ # a seed bundle contributes NO entry here and cannot: this table is keyed by node identity, and the
3524
+ # only object a bundle has is a {DefHandle}. Issue #707 — the chain travels ON the handle instead, and
3525
+ # {DefNodeResolver} re-attaches it to the node it mints, so both paths answer the same chain for the
3526
+ # same body without either one keying a table by an object the other never sees.
3527
+ acc[:def_nestings].merge!(file_index[:def_nestings] || {})
2469
3528
  fold_ancestry_tables(acc, file_index)
3529
+ fold_constant_tables(acc, file_index)
3530
+ end
3531
+
3532
+ # Issue #644 — the census folds per (name, path), so a re-folded file replaces exactly its own
3533
+ # contribution and the fold stays order-independent. `|| {}` keeps it total over a pre-#644 seed
3534
+ # bundle, which carries no census.
3535
+ def fold_constant_tables(acc, file_index)
3536
+ (file_index[:constant_writes] || {}).each do |name, by_path|
3537
+ (acc[:constant_writes][name] ||= {}).merge!(by_path)
3538
+ end
2470
3539
  end
2471
3540
 
2472
3541
  # def_nodes / singleton_def_nodes / method_visibilities / methods fold class-nested later-wins;
@@ -2492,12 +3561,21 @@ module Rigor
2492
3561
  # superclasses later-wins; includes / class_sources accumulate; member layouts later-wins.
2493
3562
  def fold_ancestry_tables(acc, file_index)
2494
3563
  acc[:superclasses].merge!(file_index[:superclasses])
2495
- file_index[:includes].each { |cn, mods| acc[:includes][cn] = ((acc[:includes][cn] || []) + mods).uniq }
3564
+ # Issue #682 a pre-#682 seed bundle carries no header nestings; the SCHEMA bump makes such a blob a
3565
+ # cold rebuild, but default so any in-flight fold stays total and simply peels for those classes.
3566
+ merge_header_nestings(acc[:header_nestings], file_index[:header_nestings] || {})
3567
+ accumulate_module_lists(acc[:includes], file_index[:includes])
3568
+ accumulate_module_lists(acc[:extends], file_index[:extends] || {})
2496
3569
  file_index[:class_sources].each { |cn, files| (acc[:class_sources][cn] ||= Set.new).merge(files) }
2497
3570
  acc[:data_member_layouts].merge!(file_index[:data_member_layouts])
2498
3571
  acc[:struct_member_layouts].merge!(file_index[:struct_member_layouts])
2499
3572
  end
2500
3573
 
3574
+ # Shared accumulate-and-dedupe fold for the class -> module-name-list tables (includes / extends).
3575
+ def accumulate_module_lists(target, additions)
3576
+ additions.each { |cn, mods| target[cn] = ((target[cn] || []) + mods).uniq }
3577
+ end
3578
+
2501
3579
  # ADR-85 WD2 — converts a file's live single-file index + its class table into a Marshal-clean seed
2502
3580
  # bundle: the plain-data tables verbatim, the def-node tables re-expressed as `[node_id, name,
2503
3581
  # fingerprint]` triples (the path is the bundle key), the class-source names (path implicit), and the
@@ -2514,15 +3592,22 @@ module Rigor
2514
3592
  # value stored here equals the one a later recheck recomputes for an unchanged declaration.
2515
3593
  declaration_signature: declaration_signature(file_index),
2516
3594
  classes: file_classes,
2517
- def_nodes: live_defs_to_bundle(file_index[:def_nodes]),
2518
- singleton_def_nodes: live_defs_to_bundle(file_index[:singleton_def_nodes]),
3595
+ extends: file_index[:extends],
3596
+ def_nodes: live_defs_to_bundle(file_index[:def_nodes], file_index[:def_nestings]),
3597
+ singleton_def_nodes: live_defs_to_bundle(file_index[:singleton_def_nodes], file_index[:def_nestings]),
2519
3598
  def_sources: file_index[:def_sources],
2520
3599
  singleton_def_sources: file_index[:singleton_def_sources],
2521
3600
  superclasses: file_index[:superclasses],
3601
+ # Issue #682 — plain `{class name => Array[String]}` data, so the bundle stays Marshal-clean and a
3602
+ # warm incremental file resolves its ancestor names the way a cold walk of it does.
3603
+ header_nestings: file_index[:header_nestings],
2522
3604
  includes: file_index[:includes],
2523
3605
  method_visibilities: file_index[:method_visibilities],
2524
3606
  methods: file_index[:methods],
2525
3607
  class_source_names: file_index[:class_sources].keys,
3608
+ # Issue #644 — the file's publication census (`name => [literal] | :unpublishable`; the path is
3609
+ # the bundle key). Plain data, so the bundle stays Marshal-clean.
3610
+ constant_writes: file_index[:constant_writes].transform_values { |by_path| by_path.values.first },
2526
3611
  data_member_layouts: file_index[:data_member_layouts],
2527
3612
  struct_member_layouts: file_index[:struct_member_layouts]
2528
3613
  }
@@ -2540,42 +3625,60 @@ module Rigor
2540
3625
  # but default to `{}` so any in-flight fold stays total.
2541
3626
  singleton_def_sources: bundle[:singleton_def_sources] || {},
2542
3627
  superclasses: bundle[:superclasses],
3628
+ header_nestings: bundle[:header_nestings] || {},
2543
3629
  includes: bundle[:includes],
3630
+ # #526 — pre-extends bundles lack the key; default `{}` keeps the fold total.
3631
+ extends: bundle[:extends] || {},
2544
3632
  method_visibilities: bundle[:method_visibilities],
2545
3633
  methods: bundle[:methods],
2546
3634
  class_sources: bundle[:class_source_names].to_h { |name| [name, Set[path]] },
3635
+ # Issue #644 — a pre-#644 bundle carries no census; the SCHEMA bump makes such a blob a cold
3636
+ # rebuild, but default so any in-flight fold stays total.
3637
+ constant_writes: (bundle[:constant_writes] || {}).transform_values { |descriptor| { path => descriptor } },
2547
3638
  data_member_layouts: bundle[:data_member_layouts],
2548
3639
  struct_member_layouts: bundle[:struct_member_layouts]
2549
3640
  }
2550
3641
  end
2551
3642
 
2552
- # `{class => {method => Prism::DefNode}}` → `{class => {method => [node_id, name, fingerprint]}}`.
2553
- def live_defs_to_bundle(defs)
3643
+ # `{class => {method => Prism::DefNode}}` → `{class => {method => [node_id, name, fingerprint,
3644
+ # nesting]}}`. Issue #707 — `nestings` is this file's identity-keyed `{DefNode => Module.nesting}` table,
3645
+ # read here while the live nodes are still in hand, because it is the LAST moment the two can be paired:
3646
+ # the bundle's reader has only the row. Issue #716 — `[]` for a top-level def, which records the empty
3647
+ # chain; nil only for a def no declaration walk reached.
3648
+ def live_defs_to_bundle(defs, nestings)
2554
3649
  defs.transform_values do |methods|
2555
3650
  methods.transform_values do |node|
2556
- [node.node_id, node.name.to_s, Digest::SHA256.hexdigest(node.location.slice)]
3651
+ [node.node_id, node.name.to_s, Digest::SHA256.hexdigest(node.location.slice), nestings[node]]
2557
3652
  end
2558
3653
  end
2559
3654
  end
2560
3655
 
2561
- # `{class => {method => [node_id, name, fingerprint]}}` → `{class => {method => DefHandle}}` for `path`.
3656
+ # `{class => {method => [node_id, name, fingerprint, nesting]}}` → `{class => {method => DefHandle}}`
3657
+ # for `path`.
2562
3658
  def bundle_defs_to_handles(defs, path)
2563
3659
  defs.transform_values do |methods|
2564
- methods.transform_values do |(node_id, name, fingerprint)|
2565
- DefHandle.new(path: path, node_id: node_id, name: name, fingerprint: fingerprint)
3660
+ methods.transform_values do |(node_id, name, fingerprint, nesting)|
3661
+ DefHandle.new(path: path, node_id: node_id, name: name, fingerprint: fingerprint, nesting: nesting)
2566
3662
  end
2567
3663
  end
2568
3664
  end
2569
3665
 
2570
3666
  # The empty per-run accumulator the def-index passes fold each file into.
2571
3667
  def new_def_index_accumulator
2572
- { def_nodes: {}, singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
2573
- superclasses: {}, includes: {}, method_visibilities: {}, methods: {}, class_sources: {},
3668
+ { def_nodes: {}, def_nestings: {}.compare_by_identity,
3669
+ singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
3670
+ superclasses: {}, header_nestings: {}, includes: {}, extends: {}, method_visibilities: {}, methods: {},
3671
+ class_sources: {},
3672
+ constant_writes: {},
2574
3673
  data_member_layouts: {}, struct_member_layouts: {} }
2575
3674
  end
2576
3675
 
2577
3676
  # Post-processes and freezes a fully-folded def-index accumulator.
2578
3677
  def finalize_def_index(acc)
3678
+ # Issue #644 — resolve the cross-file constant-reassignment rule here, where the whole project's
3679
+ # write census is known, and turn the surviving literals into their published `Type::Constant`.
3680
+ acc[:constant_values], acc[:constant_sources] = finalize_constant_writes(acc[:constant_writes])
3681
+ fold_extends_into_singleton_tables(acc[:extends], acc[:def_nodes], acc[:singleton_def_nodes], acc[:methods])
2579
3682
  # Cross-file method suppression is for the project's OWN accessors (attr_* / define_method / alias) — NOT for
2580
3683
  # plain `def`s. A cross-file `def` on a class is exactly the ADR-17 monkey-patch case the undefined-method rule
2581
3684
  # deliberately surfaces (fire + def-site annotation, nudging `pre_eval:`), so dropping the `def`-declared names
@@ -2583,7 +3686,7 @@ module Rigor
2583
3686
  # for `obj.x` in another.
2584
3687
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
2585
3688
  %i[def_nodes singleton_def_nodes def_sources singleton_def_sources includes method_visibilities
2586
- methods class_sources].each do |key|
3689
+ methods class_sources constant_sources].each do |key|
2587
3690
  acc[key].each_value(&:freeze)
2588
3691
  end
2589
3692
  acc.transform_values(&:freeze)
@@ -2616,23 +3719,35 @@ module Rigor
2616
3719
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
2617
3720
  file_methods, file_def_nodes = build_methods_and_def_nodes(root, path)
2618
3721
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
3722
+ # Issue #681 — node-identity keyed, so this is a flat union: no two files can contribute the same key.
3723
+ acc[:def_nestings].merge!(build_def_nestings(root))
2619
3724
  # ADR-46 slice 4 (singleton) — record the singleton-side `"path:line"` sources alongside the nodes,
2620
3725
  # the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
2621
3726
  # produces a changed `"Class.method"` fingerprint pair (and its call sites a symbol edge) instead of
2622
3727
  # silently degrading to the file's full ancestry closure.
2623
3728
  merge_discovered_defs(acc[:singleton_def_nodes], acc[:singleton_def_sources], path,
2624
3729
  build_discovered_singleton_def_nodes(root))
2625
- superclasses = build_discovered_superclasses(root, path)
3730
+ superclasses, header_nestings = build_superclass_tables(root, path)
2626
3731
  includes = build_discovered_includes(root)
2627
3732
  acc[:superclasses].merge!(superclasses)
2628
- includes.each do |class_name, mods|
2629
- acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq
2630
- end
3733
+ merge_header_nestings(acc[:header_nestings], header_nestings)
3734
+ accumulate_module_lists(acc[:includes], includes)
3735
+ accumulate_module_lists(acc[:extends], build_discovered_extends(root))
2631
3736
  record_class_sources(acc[:class_sources], path, root, superclasses, includes, file_def_nodes)
3737
+ merge_constant_literal_tables(acc, root, path)
2632
3738
  merge_class_keyed_index_tables(acc, root, file_methods)
2633
3739
  merge_member_layout_tables(acc, root)
2634
3740
  end
2635
3741
 
3742
+ # Issue #644 — folds one file's publication census into the cross-file accumulator, keyed by
3743
+ # (name, path) (kept out of {#accumulate_project_index} to hold its ABC budget). The conflict rule that
3744
+ # makes the fold order irrelevant runs at {#finalize_constant_writes}.
3745
+ def merge_constant_literal_tables(acc, root, path)
3746
+ constant_writes_for_file(root).each do |name, descriptor|
3747
+ (acc[:constant_writes][name] ||= {})[path] = descriptor
3748
+ end
3749
+ end
3750
+
2636
3751
  # Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
2637
3752
  # {#accumulate_project_index} to hold its ABC budget).
2638
3753
  def merge_member_layout_tables(acc, root)
@@ -2668,6 +3783,220 @@ module Rigor
2668
3783
  names.each { |name| (class_sources[name] ||= Set.new) << path }
2669
3784
  end
2670
3785
 
3786
+ # Issue #644 — the cross-file VALUE-constant pre-pass, the twin of {#record_class_sources} for plain
3787
+ # constant ASSIGNMENTS. Returns one file's **publication census**: `{qualified name => descriptor}`,
3788
+ # where the descriptor is either `[literal]` (a publishable frozen scalar) or {CONSTANT_UNPUBLISHABLE}.
3789
+ #
3790
+ # EVERY constant write is censused, whatever its rvalue and whatever its form, because the census is
3791
+ # three things at once and only the first cares about the value:
3792
+ #
3793
+ # 1. the published table ({#finalize_constant_writes}: a name publishes only when exactly one file
3794
+ # writes it and that write is a literal),
3795
+ # 2. the attribution the ADR-46 positive edge reads, and
3796
+ # 3. the producer whose per-file DIFF re-checks readers on an incremental run
3797
+ # ({Analysis::Incremental.changed_constant_publications}).
3798
+ #
3799
+ # A write the census cannot see does not merely lose precision — it silently bypasses the conflict rule
3800
+ # and publishes a value the program does not have. That is why the operator / multi-assign / chained /
3801
+ # `self::` forms below are censused as unpublishable rather than skipped.
3802
+ #
3803
+ # **Why the publishable set is only Symbol / Integer / Float.** Publishing turns `Dynamic[top]` into a
3804
+ # real type at every reader in the project, which is precisely where a wrong answer becomes a diagnostic
3805
+ # on correct code (AGENTS.md § "Implementation Guidelines"), so the recognised set is the one whose
3806
+ # precision is worth that risk.
3807
+ #
3808
+ # - `String` / `Array` / `Hash` literals are MUTABLE. A sibling file's `PATH << "/x"` or `LIST.push(...)`
3809
+ # moves the value this table pinned, and only the DECLARING file's `widen_mutated_constants` census can
3810
+ # see such a mutation. The composite two additionally carry the `HashShape` / `Tuple` hazard
3811
+ # {PreEvalConstants} documents (a closed `HashShape` makes `CONFIG.fetch(:b)` fire; a `Tuple` routes
3812
+ # through `ShapeDispatch` instead of the RBS overload a call site relied on).
3813
+ # - `nil` declines for {PreEvalConstants}' reason: at declaration position it is a placeholder for a
3814
+ # value assigned later far more often than it is a genuine `NilClass`.
3815
+ # - `true` / `false` DO publish. They are branched on rather than dispatched to, so the value would fuel
3816
+ # `flow.always-truthy-condition` at every `if FEATURE_FLAG` in another file; that is withheld at the
3817
+ # rule instead ({Analysis::CheckRules::PublishedConstantGuard}), which is the repo's own answer to a
3818
+ # value that is real but must not be concluded from, and it keeps the treatment of every published
3819
+ # kind the same.
3820
+ #
3821
+ # Everything else — a method call, a constant alias, `ENV[...]`, a `Data.define` — is not a literal, so
3822
+ # the reader keeps today's `Dynamic[top]`. Declining is always the safe answer.
3823
+ #
3824
+ # A name written TWICE in one file is unpublishable too, which is what makes a conditional
3825
+ # `X = :a if c` / `X = :b` pair gradual rather than pinned to whichever arm the walk saw first.
3826
+ def constant_writes_for_file(root)
3827
+ writes = {}
3828
+ walk_constant_write_census(root, [], writes, Set.new)
3829
+ writes
3830
+ end
3831
+
3832
+ # Mirrors {#walk_constant_writes}'s traversal for the forms it shares — the lexical class/module prefix
3833
+ # qualifies a bare `ConstantWriteNode` — and extends it to the four forms that walk misses, each
3834
+ # censused as unpublishable: an operator / `&&=` /
3835
+ # `||=` constant write, a `ConstantTargetNode` under a `MultiWriteNode` (`A, B = :x, :y`), a `self::X =`
3836
+ # whose target renders no qualified name, and the inner write of a chain (`C = D = :x`), which is why a
3837
+ # `ConstantWriteNode` descends into its own rvalue instead of returning.
3838
+ def walk_constant_write_census(node, qualified_prefix, writes, seen, self_owner = nil)
3839
+ return unless node.is_a?(Prism::Node)
3840
+
3841
+ case node
3842
+ when Prism::ClassNode, Prism::ModuleNode
3843
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
3844
+ if child_prefix
3845
+ walk_constant_write_census(node.body, child_prefix, writes, seen) if node.body
3846
+ return
3847
+ end
3848
+ else
3849
+ census_constant_write(node, qualified_prefix, writes, seen, self_owner)
3850
+ end
3851
+
3852
+ rebound = rebound_block_self(node, qualified_prefix)
3853
+ node.rigor_each_child do |child|
3854
+ owner = rebound && child.is_a?(Prism::BlockNode) ? rebound : self_owner
3855
+ walk_constant_write_census(child, qualified_prefix, writes, seen, owner)
3856
+ end
3857
+ end
3858
+
3859
+ # Censuses `node` when it is any constant-assigning form. Only a plain `ConstantWriteNode` /
3860
+ # `ConstantPathWriteNode` can contribute a VALUE; every other form is recorded unpublishable, which
3861
+ # suppresses publication of the name exactly as a second file's write would.
3862
+ def census_constant_write(node, qualified_prefix, writes, seen, self_owner = nil)
3863
+ case node
3864
+ when Prism::ConstantWriteNode
3865
+ record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s),
3866
+ constant_literal_value(node.value), writes, seen)
3867
+ when Prism::ConstantPathWriteNode
3868
+ record_constant_write_census(constant_path_write_name(node.target, qualified_prefix, self_owner),
3869
+ constant_path_write_literal(node, self_owner), writes, seen)
3870
+ when Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
3871
+ record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s), nil, writes, seen)
3872
+ when Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
3873
+ record_constant_write_census(constant_path_write_name(node.target, qualified_prefix, self_owner),
3874
+ nil, writes, seen)
3875
+ when Prism::MultiWriteNode
3876
+ census_multi_write_constants(node, qualified_prefix, writes, seen, self_owner)
3877
+ end
3878
+ end
3879
+
3880
+ # `A, B = :x, :y` and `A, *rest = …`. A destructured element's value is a projection of the right-hand
3881
+ # side, which this syntactic walk does not evaluate, so every constant target is censused unpublishable.
3882
+ def census_multi_write_constants(node, qualified_prefix, writes, seen, self_owner = nil)
3883
+ targets = node.lefts + node.rights
3884
+ targets << node.rest if node.rest
3885
+ targets.each do |target|
3886
+ case target
3887
+ when Prism::ConstantTargetNode
3888
+ record_constant_write_census(qualified_write_name(qualified_prefix, target.name.to_s), nil, writes, seen)
3889
+ when Prism::ConstantPathTargetNode
3890
+ record_constant_write_census(constant_path_write_name(target, qualified_prefix, self_owner),
3891
+ nil, writes, seen)
3892
+ end
3893
+ end
3894
+ end
3895
+
3896
+ # The qualified name a constant-path write targets, for the PUBLICATION census only. `Foo::BAR = …`
3897
+ # renders as written — deliberately NOT what {#constant_path_write_key} answers for the typed table,
3898
+ # which resolves the namespace through the enclosing nesting
3899
+ # ([#690](https://github.com/rigortype/rigor/issues/690)). Moving this one changes which name a value
3900
+ # publishes to the whole project, an over-suppression question with its own safety story, so the two
3901
+ # censuses key a path write differently until that is settled.
3902
+ # `self::BAR = …` names whatever `self` is at that point, which the walk carries in `self_owner`
3903
+ # ([#705](https://github.com/rigortype/rigor/issues/705)) — the enclosing lexical namespace in an
3904
+ # ordinary body, the receiver inside a `class_eval` block. Where that `self` is {OPAQUE_SELF}, and for
3905
+ # any other dynamic receiver (`klass::BAR = …`), the target is "some class, then `::BAR`" and the name
3906
+ # falls back to the bare last segment — the one spelling that class could make it, `Object`. Suppressing
3907
+ # a top-level name is gradual typing, which is the safe direction here; the VALUE is what such a write
3908
+ # must not contribute ({#constant_path_write_literal}).
3909
+ def constant_path_write_name(target, qualified_prefix, self_owner = nil)
3910
+ full = Source::ConstantPath.qualified_name_or_nil(target)
3911
+ return full if full
3912
+
3913
+ base = target.name&.to_s
3914
+ return nil if base.nil?
3915
+ return base unless target.parent.is_a?(Prism::SelfNode)
3916
+
3917
+ self_write_name(qualified_prefix, self_owner, base) || base
3918
+ end
3919
+
3920
+ # The publishable literal a path write contributes — none, whenever its base is not statically
3921
+ # nameable ([#705](https://github.com/rigortype/rigor/issues/705)). `[Foo].each { |k| k::X = 1 }`
3922
+ # renders as the bare `X`, and publishing `1` under it handed every reader of the top-level `X` a value
3923
+ # the program never has: `X == 2` folded to `false`, and in the WRITING file — where
3924
+ # `Scope#local_constant_names` exempts the name from #644's withholding guard —
3925
+ # `flow.always-truthy-condition` then fired on correct code.
3926
+ #
3927
+ # The NAME still enters the census, because a decline means opposite things in the two censuses. In the
3928
+ # typed one, recording no type is gradual. Here, recording nothing would leave ANOTHER file's value for
3929
+ # the name trusted — silence is the risky direction, and the census's own gradual answer is to say the
3930
+ # name was touched and no value for it is safe.
3931
+ def constant_path_write_literal(node, self_owner)
3932
+ return nil unless nameable_write_target?(node.target, self_owner)
3933
+
3934
+ constant_literal_value(node.value)
3935
+ end
3936
+
3937
+ # True when the census can name what the write targets: a static constant path, or a `self::BAR` whose
3938
+ # `self` the walk resolved to a class or module.
3939
+ def nameable_write_target?(target, self_owner)
3940
+ return true if Source::ConstantPath.qualified_name_or_nil(target)
3941
+ return false unless target.parent.is_a?(Prism::SelfNode)
3942
+
3943
+ !self_owner.equal?(OPAQUE_SELF)
3944
+ end
3945
+
3946
+ def qualified_write_name(qualified_prefix, base_name)
3947
+ qualified_prefix.empty? ? base_name : "#{qualified_prefix.join('::')}::#{base_name}"
3948
+ end
3949
+
3950
+ # Records one censused write. `seen` carries the names this file has already written, so a repeat write
3951
+ # retracts the publishable descriptor whatever either rvalue was — neither arm of an in-file
3952
+ # reassignment is ever published, and the name still counts as written.
3953
+ def record_constant_write_census(full, literal, writes, seen)
3954
+ return if full.nil?
3955
+
3956
+ writes[full] = seen.add?(full) ? (literal || CONSTANT_UNPUBLISHABLE) : CONSTANT_UNPUBLISHABLE
3957
+ end
3958
+
3959
+ # The frozen-scalar literal fold: `[value]` for a publishable rvalue, nil to decline. The one-element
3960
+ # Array is the presence wrapper (`false` is a legitimate published value), and the raw Ruby value is
3961
+ # what rides the ADR-85 seed bundle through `Marshal`.
3962
+ def constant_literal_value(rvalue)
3963
+ case rvalue
3964
+ when Prism::SymbolNode then symbol_literal_publication(rvalue)
3965
+ when Prism::IntegerNode, Prism::FloatNode then [rvalue.value]
3966
+ when Prism::TrueNode then [true]
3967
+ when Prism::FalseNode then [false]
3968
+ end
3969
+ end
3970
+
3971
+ # `Prism::SymbolNode` is the STATIC symbol node (a dynamic `:"#{x}"` parses as
3972
+ # `InterpolatedSymbolNode` and never reaches here), so its unescaped text is the whole value.
3973
+ def symbol_literal_publication(node)
3974
+ text = node.unescaped
3975
+ text.is_a?(String) ? [text.to_sym] : nil
3976
+ end
3977
+
3978
+ # Issue #644 — materialises the published table and the write attribution from the folded census.
3979
+ #
3980
+ # A name publishes only when exactly ONE file writes it and that write is a literal. Two files writing
3981
+ # the same qualified name is a cross-file reassignment whose winner is load order, so the name stays
3982
+ # `Dynamic[top]` — the type it already had, and the widest there is. The rule runs at finalize (not at
3983
+ # fold time) so the fold stays order-independent, which is what lets the ADR-85 incremental path re-fold
3984
+ # a changed file's contribution in place.
3985
+ #
3986
+ # @return [Array(Hash, Hash)] `[{name => Type}, {name => Set[path]}]`.
3987
+ def finalize_constant_writes(writes)
3988
+ values = {}
3989
+ sources = {}
3990
+ writes.each do |name, by_path|
3991
+ sources[name] = by_path.keys.to_set.freeze
3992
+ next unless by_path.size == 1
3993
+
3994
+ descriptor = by_path.values.first
3995
+ values[name] = Type::Combinator.constant_of(descriptor.first) if descriptor.is_a?(Array)
3996
+ end
3997
+ [values, sources]
3998
+ end
3999
+
2671
4000
  # Merges one file's `class → method → DefNode` map into the cross-file `def_nodes` index and records each method's
2672
4001
  # first- seen `"path:line"` definition site in `def_sources` (ADR-17 — the un-registered-project-patch signal
2673
4002
  # `call.undefined-method` and `rigor triage` key on).
@@ -2688,11 +4017,11 @@ module Rigor
2688
4017
 
2689
4018
  case node
2690
4019
  when Prism::ClassNode, Prism::ModuleNode
2691
- name = Source::ConstantPath.qualified_name(node.constant_path)
2692
- if name
2693
- full = (qualified_prefix + [name]).join("::")
4020
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
4021
+ if child_prefix
4022
+ full = child_prefix.join("::")
2694
4023
  accumulator[full] = Type::Combinator.singleton_of(full)
2695
- return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body
4024
+ return collect_class_decls(node.body, child_prefix, accumulator) if node.body
2696
4025
  end
2697
4026
  when Prism::ConstantWriteNode
2698
4027
  record_class_new_constant_decl(node, qualified_prefix, accumulator)
@@ -2775,7 +4104,32 @@ module Rigor
2775
4104
  identity_table = {}.compare_by_identity
2776
4105
  discovered = {}
2777
4106
  record_declarations(root, [], identity_table, discovered)
2778
- [identity_table.freeze, discovered.freeze]
4107
+ [identity_table.freeze, synthesize_namespace_prefixes(discovered).freeze]
4108
+ end
4109
+
4110
+ # Issue #528 — every proper prefix of a discovered COMPACT class name is a namespace module that
4111
+ # provably exists at runtime, even when no `module X` declaration is anywhere in the source
4112
+ # (Zeitwerk derives it from the directory: mastodon writes `class Api::V1::AccountsController`
4113
+ # and never `module Api`). Registering the prefixes lets a bare `Api` read — and the inner
4114
+ # ConstantReadNodes of every resolving constant path — type as the namespace singleton instead of
4115
+ # falling to the unresolved fallback (~500 sites on mastodon). An explicitly-declared name always
4116
+ # wins: prefixes never overwrite an existing entry.
4117
+ def synthesize_namespace_prefixes(discovered)
4118
+ additions = {}
4119
+ discovered.each_key do |full|
4120
+ next unless full.include?("::")
4121
+
4122
+ segments = full.split("::")
4123
+ (1...segments.size).each do |keep|
4124
+ prefix = segments.first(keep).join("::")
4125
+ next if discovered.key?(prefix) || additions.key?(prefix)
4126
+
4127
+ additions[prefix] = Type::Combinator.singleton_of(prefix)
4128
+ end
4129
+ end
4130
+ return discovered if additions.empty?
4131
+
4132
+ discovered.merge(additions)
2779
4133
  end
2780
4134
 
2781
4135
  def record_declarations(node, qualified_prefix, identity_table, discovered)
@@ -2794,14 +4148,13 @@ module Rigor
2794
4148
  end
2795
4149
 
2796
4150
  def record_class_or_module?(node, qualified_prefix, identity_table, discovered)
2797
- name = Source::ConstantPath.qualified_name(node.constant_path)
2798
- return false unless name
4151
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
4152
+ return false unless child_prefix
2799
4153
 
2800
- full = (qualified_prefix + [name]).join("::")
4154
+ full = child_prefix.join("::")
2801
4155
  singleton = Type::Combinator.singleton_of(full)
2802
4156
  identity_table[node.constant_path] = singleton
2803
4157
  discovered[full] = singleton
2804
- child_prefix = qualified_prefix + [name]
2805
4158
  record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
2806
4159
  true
2807
4160
  end
@@ -2818,7 +4171,8 @@ module Rigor
2818
4171
  # The block body, if present, is recursed into so any nested class/module declarations in the override block (rare
2819
4172
  # but legal) still feed the discovered table.
2820
4173
  def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
2821
- return false unless data_define_call?(node.value) || struct_new_call?(node.value)
4174
+ factory_call = resolve_meta_factory_call(node.value)
4175
+ return false unless factory_call
2822
4176
 
2823
4177
  full = (qualified_prefix + [node.name.to_s]).join("::")
2824
4178
  discovered[full] = Type::Combinator.singleton_of(full)
@@ -2932,6 +4286,5 @@ module Rigor
2932
4286
  propagate(node.else_clause, table, truthy_scope) if node.else_clause
2933
4287
  end
2934
4288
  end
2935
- # rubocop:enable Metrics/ModuleLength
2936
4289
  end
2937
4290
  end