rigortype 0.3.5 → 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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  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 +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -11,17 +11,21 @@ require_relative "../source/constant_path"
11
11
  require_relative "anonymous_meta_class"
12
12
  require_relative "block_parameter_binder"
13
13
  require_relative "body_fixpoint"
14
+ require_relative "captured_locals"
14
15
  require_relative "dynamic_origin"
15
16
  require_relative "../analysis/check_rules/inferred_param_guard"
16
17
  require_relative "struct_fold_safety"
17
18
  require_relative "closure_escape_analyzer"
19
+ require_relative "content_join"
18
20
  require_relative "indexed_narrowing"
21
+ require_relative "index_write_widening"
19
22
  require_relative "method_dispatcher"
20
23
  require_relative "method_parameter_binder"
21
24
  require_relative "multi_target_binder"
22
25
  require_relative "mutation_widening"
23
26
  require_relative "narrowing"
24
27
  require_relative "optimistic_origin"
28
+ require_relative "version_guard"
25
29
 
26
30
  module Rigor
27
31
  module Inference
@@ -52,7 +56,7 @@ module Rigor
52
56
  #
53
57
  # See docs/internal-spec/inference-engine.md for the public contract and docs/adr/4-type-inference-engine.md for the
54
58
  # slice rationale.
55
- # rubocop:disable Metrics/ClassLength
59
+ # rubocop:disable-next Metrics/ClassLength
56
60
  class StatementEvaluator
57
61
  # Hash-based dispatch keeps `evaluate` linear and lets future slices add control-flow node kinds without growing a
58
62
  # single case statement past RuboCop's cyclomatic budget. Anonymous Prism subclasses are not expected.
@@ -76,7 +80,11 @@ module Rigor
76
80
  Prism::GlobalVariableAndWriteNode => :eval_global_and_write,
77
81
  Prism::GlobalVariableOperatorWriteNode => :eval_global_operator_write,
78
82
  Prism::IndexOrWriteNode => :eval_index_or_write,
83
+ Prism::IndexAndWriteNode => :eval_index_write,
84
+ Prism::IndexOperatorWriteNode => :eval_index_write,
79
85
  Prism::MultiWriteNode => :eval_multi_write,
86
+ Prism::ConstantWriteNode => :eval_constant_write,
87
+ Prism::ConstantPathWriteNode => :eval_constant_write,
80
88
  Prism::IfNode => :eval_if,
81
89
  Prism::UnlessNode => :eval_unless,
82
90
  Prism::ElseNode => :eval_else,
@@ -128,6 +136,15 @@ module Rigor
128
136
  # singleton-method RBS lookups.
129
137
  ClassFrame = Data.define(:name, :singleton)
130
138
 
139
+ # Issue #652 — Ruby's `Module.nesting` for the body currently being evaluated, innermost first, built as
140
+ # the walk ENTERS each declaration rather than reconstructed from the qualified name afterwards. A
141
+ # compact `module A::B` contributes ONE entry, the nested `module A; module B` two, and both render the
142
+ # same `class_name`, so the distinction survives only if it is recorded here. `[]` is the file top level,
143
+ # and nothing is stamped for it — a scope with no recorded chain falls back to the name-peel, which
144
+ # answers the same thing at the top level and answers correctly for a body this walk never entered.
145
+ EMPTY_NESTING = [].freeze
146
+ private_constant :EMPTY_NESTING
147
+
131
148
  # @param scope [Rigor::Scope]
132
149
  # @param tracer [Rigor::Inference::FallbackTracer, nil]
133
150
  # @param on_enter [#call, nil] optional `(node, scope) ->` callable
@@ -150,11 +167,12 @@ module Rigor
150
167
  # `Integer`). Display-path only — `rigor check` leaves it off,
151
168
  # keeping its diagnostics and wall-clock unchanged.
152
169
  def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze,
153
- converged_loop_recording: false)
170
+ lexical_nesting: EMPTY_NESTING, converged_loop_recording: false)
154
171
  @scope = scope
155
172
  @tracer = tracer
156
173
  @on_enter = on_enter
157
174
  @class_context = class_context.freeze
175
+ @lexical_nesting = lexical_nesting.freeze
158
176
  @converged_loop_recording = converged_loop_recording
159
177
  end
160
178
 
@@ -309,6 +327,48 @@ module Rigor
309
327
  [rhs_type, post_rhs.with_global(node.name, rhs_type)]
310
328
  end
311
329
 
330
+ # `Const = rvalue` / `A::Const = rvalue`. The pair is exactly what the default expression path produced before
331
+ # this handler existed — the expression typer types the rvalue and the scope comes back unchanged, a constant
332
+ # write binding no local — plus one walk the default path could not make. Issue #590: when the rvalue is a
333
+ # class-creating meta call with a literal block (`Const = Struct.new(:text) do … end` and its `Data.define` /
334
+ # `Class.new` / `Module.new` twins) the block is a CLASS BODY, `class_eval`'d on the class the call creates,
335
+ # and it is entered as one here — the constant-write counterpart of {#evaluate_block_if_present}'s #319 arm.
336
+ # Left to the default path the body was never walked at all, so `ScopeIndexer.propagate` handed every node
337
+ # inside the ENCLOSING scope: at file top level a nil `self_type`, which made `Scope#toplevel?` hold inside
338
+ # every `def` of the body and `call.unresolved-toplevel` fire on the struct's own member reads (and on
339
+ # `attr_reader`, the very macro #319 silenced at every other position); inside a module, the module's own
340
+ # `self` — a wrong receiver for every implicit-self call in the body.
341
+ def eval_constant_write(node)
342
+ result = [scope.type_of(node, tracer: tracer), scope]
343
+ context = meta_new_constant_body_context(node)
344
+ return result if context.nil?
345
+
346
+ call_node = node.value
347
+ enter_meta_class_body(call_node.block, build_block_entry_scope(call_node, call_node.block), context)
348
+ result
349
+ end
350
+
351
+ # The class context a meta-new rvalue block is entered under, or nil when the rvalue is not that shape. The
352
+ # KEY must be the one `ScopeIndexer` filed the body's defs and member layout under, so the two passes agree —
353
+ # which is why the decision is delegated to the ScopeIndexer's own recognition rather than re-spelled here. A
354
+ # `Prism::ConstantWriteNode` whose rvalue `ScopeIndexer.meta_new_block_body` recognises is keyed by the
355
+ # constant's qualified name, one frame appended to the lexical context exactly as a `class Const` keyword body
356
+ # would be. Every other block form — a constant-PATH write (`A::B = Struct.new do … end`, which has no
357
+ # constant-keyed registration) or a shape the ScopeIndexer's stricter argument check rejects
358
+ # (`Const = Struct.new(*names) do … end`) — was registered under the call site's anonymous name and is
359
+ # entered under that.
360
+ def meta_new_constant_body_context(node)
361
+ call_node = node.value
362
+ return nil unless call_node.is_a?(Prism::CallNode) && call_node.block.is_a?(Prism::BlockNode)
363
+
364
+ if node.is_a?(Prism::ConstantWriteNode) && ScopeIndexer.meta_new_block_body(node)
365
+ return @class_context + [ClassFrame.new(name: node.name.to_s, singleton: false)]
366
+ end
367
+
368
+ anonymous = AnonymousMetaClass.name_for(call_node, scope.source_path)
369
+ anonymous && [ClassFrame.new(name: anonymous, singleton: false)]
370
+ end
371
+
312
372
  # Slice 7 phase 3 — compound writes (||=, &&=, +=/-=/...) for every variable kind. Each handler:
313
373
  # 1. Reads the current type from the appropriate scope
314
374
  # binding map (or `Dynamic[Top]` when unbound).
@@ -432,12 +492,72 @@ module Rigor
432
492
 
433
493
  key_node = first_index_argument(node)
434
494
  address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
495
+ # Issue #544 — a receiver with an untracked (Dynamic / Top) constituent can hold a caller-supplied
496
+ # slot value the `||=` keeps, so the recorded default would invent a fact; decline the record.
497
+ address = nil if address && !IndexedNarrowing.fully_tracked_receiver_type?(scope.type_of(node.receiver))
435
498
  post = post_rhs
499
+ # Widen BEFORE recording the narrowing: rebinding the receiver drops the per-slot narrowings keyed on it, so
500
+ # the reverse order would trade this feature away for the widening. The two are complementary — the shape
501
+ # forgets that the collection is still empty, the narrowing remembers that THIS slot is now non-nil.
502
+ post = IndexWriteWidening.widen(node: node, current_scope: post,
503
+ arg_types: index_write_arg_types(node, result_type))
436
504
  post = post.with_indexed_narrowing(*address, result_type) if address
437
505
 
438
506
  [result_type, post]
439
507
  end
440
508
 
509
+ # `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
510
+ # expression, scope untouched — and the receiver never widened. They store through `[]=` exactly as
511
+ # `eval_index_or_write` does, so they take the same widening; the value itself is still typed by the expression
512
+ # typer (`type_of_assignment_write`), which is what the default did.
513
+ def eval_index_write(node)
514
+ stored = scope.type_of(node, tracer: tracer)
515
+ [stored,
516
+ IndexWriteWidening.widen(node: node, current_scope: scope,
517
+ arg_types: index_write_arg_types(node, stored))]
518
+ end
519
+
520
+ # `[key_type, stored_value_type]` for an index-write node, shaped exactly like a `[]=` call's
521
+ # argument list so the widening seam can join it the same way (issue #560). The stored value is
522
+ # the node's OWN expression type — for `t[0] += 5` that is the compound machinery's already-computed
523
+ # `t[0] + 5`, which is the whole point: it is the value the mutation put in the slot, and the one
524
+ # the retained element evidence provably no longer covers. Returns `[]` when the key is unresolvable,
525
+ # which reproduces the pre-join widening.
526
+ # There is deliberately NO `rescue` here. `Scope#type_of` is a total query over well-formed Prism input,
527
+ # so a raise is an engine bug, and swallowing it would silently downgrade a live seam to "no evidence" —
528
+ # the join would quietly stop happening with nothing to show for it. Let it reach the runner's
529
+ # internal-error path, where it is visible.
530
+ def index_write_arg_types(node, stored_type)
531
+ key_node = first_index_argument(node)
532
+ return MutationWidening::NO_ARG_TYPES if key_node.nil? || stored_type.nil?
533
+ return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, scope)
534
+
535
+ [scope.type_of(key_node, tracer: tracer), stored_type]
536
+ end
537
+
538
+ # Argument types for a straight-line content mutator (`arr << x`, `h[k] = v`).
539
+ #
540
+ # **The two scopes are different on purpose.** `current_scope` is the post-call scope the widening will
541
+ # actually rewrite, so the receiver gate has to ask IT whether the binding is still a literal-shape
542
+ # carrier — asking the entry scope could green-light a join the widening then declines, or miss one it
543
+ # would make. The arguments, though, are evaluated BEFORE the call in Ruby's order, so their types come
544
+ # from the entry `scope`; that also matches how the block path types its own mutator arguments against
545
+ # the block-ENTRY scope. Between the two points the only edits to the scope are the block / escape /
546
+ # plugin-assertion helpers above, none of which can rebind a mutator call's argument expressions.
547
+ #
548
+ # Both gates are about cost, not correctness — the join declines on its own in either case.
549
+ # `Scope#type_of` builds a fresh `ExpressionTyper` per call and memoizes nothing, so typing arguments a
550
+ # join will not consume is pure overhead, and `<<` on a String buffer is one of the commonest calls
551
+ # there is. The content-adder table skips a non-adding mutator (`pop`, `sort!`); `joinable_receiver?`
552
+ # skips a receiver whose current binding is not a literal-shape carrier.
553
+ def mutator_arg_types(call_node, current_scope)
554
+ return MutationWidening::NO_ARG_TYPES unless ContentJoin::CONTENT_ADDERS.include?(call_node.name)
555
+ return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(call_node.receiver,
556
+ current_scope)
557
+
558
+ content_arg_types(call_node, scope)
559
+ end
560
+
441
561
  def first_index_argument(node)
442
562
  args = node.arguments
443
563
  return nil if args.nil?
@@ -562,21 +682,33 @@ module Rigor
562
682
  # non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
563
683
  # dead else.
564
684
  def live_branch_for_if(node, pred_type, post_pred)
565
- verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
566
- case verdict
685
+ case branch_certainty(node.predicate, pred_type, post_pred)
567
686
  when :truthy then eval_branch_or_nil(node.statements, post_pred)
568
687
  when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
569
688
  end
570
689
  end
571
690
 
572
691
  def live_branch_for_unless(node, pred_type, post_pred)
573
- verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
574
- case verdict
692
+ case branch_certainty(node.predicate, pred_type, post_pred)
575
693
  when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
576
694
  when :falsey then eval_branch_or_nil(node.statements, post_pred)
577
695
  end
578
696
  end
579
697
 
698
+ # ADR-47 WD5 — a decidable **version guard** answers first (#627). `RUBY_VERSION >= "3.1"` and the
699
+ # `Gem::Version.new(…) <cmp> Gem::Version.new(…)` spellings fold from literals the analyzer can read, so the arm
700
+ # that cannot run on the Ruby being checked with is elided exactly as `if false`'s is: it is never evaluated, so
701
+ # it contributes no diagnostics and its writes do not join into the post-`if` scope. The guard expression's own
702
+ # type stays `bool`, which is what keeps `flow.always-truthy-condition` off it — a version guard is intentional,
703
+ # not a redundant condition. See {VersionGuard} for the folded shapes and the reference-Ruby premise.
704
+ def branch_certainty(predicate, pred_type, post_pred)
705
+ guard = VersionGuard.verdict(predicate)
706
+ return guard if guard
707
+ return nil if optimistic_carrier?(predicate, post_pred)
708
+
709
+ Narrowing.predicate_certainty(pred_type)
710
+ end
711
+
580
712
  # ADR-101 — the branch elision MUST NOT conclude truthiness from a carrier whose nil-freeness rests on
581
713
  # the `%a{implicitly-returns-nil}` that `RbsDispatch` reads past. Such a value is optimistic, not proof
582
714
  # (see {Inference::OptimisticOrigin} and docs/internal-spec/inference-engine.md), so eliding an arm on
@@ -912,7 +1044,7 @@ module Rigor
912
1044
  # writeback (a loop may content-mutate a collection without rebinding any local — `acc << x`), so apply it to
913
1045
  # the single-pass join before returning.
914
1046
  if names.empty?
915
- fast = loop_content_writeback(node.statements, base_scope)
1047
+ fast = loop_content_writeback(node.statements, base_scope, pre_body: post_pred)
916
1048
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
917
1049
  end
918
1050
 
@@ -941,9 +1073,9 @@ module Rigor
941
1073
  post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
942
1074
  # ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
943
1075
  # << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
944
- # the continuation collection (pre-state read from `post_loop` so a local both rebound and content-mutated
945
- # composes).
946
- loop_content_writeback(node.statements, post_loop)
1076
+ # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
1077
+ # from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
1078
+ loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
947
1079
  end
948
1080
 
949
1081
  # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred` loop exits PRECISELY on the predicate's
@@ -1053,9 +1185,25 @@ module Rigor
1053
1185
  # Joins loop-body content mutations into the continuation collection bindings. The mutator arguments are typed
1054
1186
  # against `post_loop`, whose locals already carry the loop-body fixpoint widening (so an appended `n` that the
1055
1187
  # loop decrements types `Integer`, not its entry `Constant[3]` — otherwise only the first iteration's value would
1056
- # be captured, an unsound under-approximation). Pre-state is read from `post_loop` too. A loop body shares the
1057
- # surrounding scope, so the receiver is any `LocalVariableReadNode` (no depth filter).
1058
- def loop_content_writeback(statements, post_loop)
1188
+ # be captured, an unsound under-approximation). A loop body shares the surrounding scope, so the receiver is any
1189
+ # `LocalVariableReadNode` (no depth filter).
1190
+ #
1191
+ # **Pre-state comes from `pre_body` — the scope as of the loop predicate — for every name the loop does not
1192
+ # REBIND.** `post_loop` derives from a body evaluation, so the straight-line join inside the body has already
1193
+ # written its own result into that binding, floor and all (issue #560: a straight-line seam sees one store, so it
1194
+ # never closes the parameter it feeds). Re-deriving on top of that is derivation on derived output: this seam
1195
+ # scans the WHOLE body and joins every store in it, so it wants the contents as they stood before the body ran
1196
+ # and re-adds the stores itself. Left on `post_loop`, ADR-56's own `acc = []; while …; acc.push(m); end` read
1197
+ # `Array[Dynamic[top] | Integer]` where it must read `Array[Integer]`.
1198
+ #
1199
+ # Scrubbing the floor back out of `post_loop` instead is NOT an option: once inside a union a `Dynamic` is
1200
+ # indistinguishable from a DECLARED one, and stripping it closes a declared `Array[Integer | untyped]` parameter
1201
+ # and draws a false `undefined method` on correct code. Telling the two apart needs provenance the carriers do
1202
+ # not have (issue #580); taking the pre-body binding sidesteps the question entirely.
1203
+ #
1204
+ # A name the loop rebinds keeps reading `post_loop`, which is what lets a local both rebound (slice B) and
1205
+ # content-mutated compose — the documented behaviour, and there the fixpoint's answer IS the right base.
1206
+ def loop_content_writeback(statements, post_loop, pre_body: nil, rebound: nil)
1059
1207
  return post_loop if statements.nil?
1060
1208
 
1061
1209
  mutations = Hash.new { |h, k| h[k] = [] }
@@ -1066,11 +1214,20 @@ module Rigor
1066
1214
  return post_loop if mutations.empty?
1067
1215
 
1068
1216
  mutations.reduce(post_loop) do |acc, (name, calls)|
1069
- joined = join_content_for_local(name, calls, acc, post_loop)
1217
+ joined = join_content_for_local(name, calls, content_seed_scope(name, acc, pre_body, rebound), post_loop)
1070
1218
  joined.nil? ? acc : acc.with_local(name, joined)
1071
1219
  end
1072
1220
  end
1073
1221
 
1222
+ # The scope a loop content join reads its PRE-STATE from: the pre-body scope for a name the loop only
1223
+ # content-mutates, and the accumulating post-loop scope for one the loop also rebinds. {#loop_content_writeback}
1224
+ # carries the why.
1225
+ def content_seed_scope(name, accumulated, pre_body, rebound)
1226
+ return accumulated if pre_body.nil? || rebound&.include?(name)
1227
+
1228
+ pre_body
1229
+ end
1230
+
1074
1231
  # Re-evaluates the loop body once from the converged fixpoint bindings, solely for the `on_enter` side effect of
1075
1232
  # re-recording the body's per-node entry scopes. Gated behind the display-path-only `converged_loop_recording`
1076
1233
  # flag so the check path neither pays the extra body evaluation nor risks any diagnostic drift.
@@ -1104,7 +1261,7 @@ module Rigor
1104
1261
  pre_existing = []
1105
1262
  body_first = []
1106
1263
  Source::NodeWalker.each(statements) do |descendant|
1107
- next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
1264
+ next unless CapturedLocals::LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
1108
1265
 
1109
1266
  name = descendant.name
1110
1267
  if base_scope.locals.key?(name)
@@ -1305,10 +1462,19 @@ module Rigor
1305
1462
  # lexical owner. The outer scope is unchanged on exit because Ruby's class definition does not bind any local in
1306
1463
  # the enclosing scope. The class body's value is the value of its last statement (`Constant[nil]` for an empty
1307
1464
  # body); we discard the body's post-scope.
1465
+ #
1466
+ # Issue #708 — a ROOTED header (`class ::Rooted::Bar`) re-anchors at the top level, so the frame stack
1467
+ # RESETS to that class alone rather than gaining a frame under the enclosure. `current_class_path` joins
1468
+ # the stack, so keeping the enclosing frames named the class `Outer::Rooted::Bar` and every `def` in it
1469
+ # registered under a name no caller writes. The nesting CHAIN is not reset the same way — Ruby leaves the
1470
+ # enclosing entries beneath the un-prefixed one — which is why {Source::ConstantPath.pushed_nesting} owns
1471
+ # that half rather than this one deriving it from the frames.
1308
1472
  def eval_class_or_module(node)
1309
- name = Source::ConstantPath.qualified_name(node.constant_path)
1310
- new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
1311
- body_type, _body_scope = eval_class_body(node, new_context)
1473
+ path = node.constant_path
1474
+ frame = ClassFrame.new(name: Source::ConstantPath.qualified_name(path), singleton: false)
1475
+ new_context = Source::ConstantPath.rooted?(path) ? [frame] : @class_context + [frame]
1476
+ body_type, _body_scope = eval_class_body(node, new_context,
1477
+ Source::ConstantPath.pushed_nesting(@lexical_nesting, path))
1312
1478
  [body_type, scope]
1313
1479
  end
1314
1480
 
@@ -1316,6 +1482,13 @@ module Rigor
1316
1482
  # class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
1317
1483
  # rather than `instance_method`. For non-`self` expressions we cannot statically resolve the receiver, so we keep
1318
1484
  # the existing context and accept that nested defs degrade to the `Dynamic[Top]` default.
1485
+ # Issue #652 — the body INHERITS this evaluator's chain. Ruby pushes the singleton class onto
1486
+ # `Module.nesting` and leaves the enclosing entries beneath it, so `class << Other` written inside
1487
+ # `module Admin` reads `Admin::Y`: the enclosing rung is live, and only the singleton rung itself
1488
+ # (`#<Class:Other>`) is one Rigor does not model, on either path
1489
+ # ([#662](https://github.com/rigortype/rigor/issues/662)). Discarding the chain here because
1490
+ # {#singleton_context_for} resets the FRAME STACK for the cross-class form would answer `Other::Y`
1491
+ # off the name-peel instead — a rung Ruby's lookup does not have at all.
1319
1492
  def eval_singleton_class(node)
1320
1493
  new_context = singleton_context_for(node)
1321
1494
  body_type, _body_scope = eval_class_body(node, new_context)
@@ -1384,7 +1557,8 @@ module Rigor
1384
1557
  # Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
1385
1558
  # (e.g. `arms << x`, `@tags << hashtag`). Stops a literal-shape carrier (`Tuple` / `HashShape`) from outliving
1386
1559
  # its justification when the value is mutated. Always-safe (loses precision, never invents facts).
1387
- post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1560
+ post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope,
1561
+ arg_types: mutator_arg_types(node, post_scope))
1388
1562
  # ADR-48 slice 4 — Struct member-setter re-typing. After `s.x = v` on a fold-safe StructInstance local, rebind
1389
1563
  # `s` to a StructInstance with member `:x` replaced by the assigned type, so a later `s.x` folds to `v` and a
1390
1564
  # sibling `s.y` stays precise. `call_type` is the setter's own result (the assigned value type). Sound only for
@@ -1402,11 +1576,16 @@ module Rigor
1402
1576
  post_scope = widen_callee_escaped_argument_captures(node, post_scope)
1403
1577
  # Same always-safe rationale as `widen_after_call` above — propagates outer-scope local / ivar widening from
1404
1578
  # block body mutations (`items.each { |x| arr << x }`).
1579
+ #
1580
+ # The slice-C join below reads its SEED from here, before the widening runs: `widen_after_block` spells an
1581
+ # empty `[]` as `Array[untyped]`, and read back after the fact that `untyped` is indistinguishable from a
1582
+ # declared one (issue #586 — see {#content_writeback_block_captures}).
1583
+ pre_widen_scope = post_scope
1405
1584
  post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1406
1585
  # ADR-56 slice C — receiver-content element-type join. Joins appended / stored element / key / value types into
1407
1586
  # the continuation collection so `out = [0]; arr.each { |x| out << x }` types `Array[0 | Integer]`, not
1408
1587
  # `Array[0]`. Same always-safe rationale (only widens).
1409
- post_scope = content_writeback_block_captures(node, post_scope)
1588
+ post_scope = content_writeback_block_captures(node, post_scope, seed_scope: pre_widen_scope)
1410
1589
  # Indexed-collection narrowing — drop any `receiver[key] ||= default` narrowing the analyzer recorded earlier
1411
1590
  # when an intervening `[]=` writes the same slot or any other mutator runs against the receiver. Always-safe
1412
1591
  # (only forgets; never invents).
@@ -1428,6 +1607,10 @@ module Rigor
1428
1607
  # whose body we cannot prove match-free). A call provably match-free on a known receiver — `$3.to_i`, `year <
1429
1608
  # 50` — does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
1430
1609
  # while a genuinely interposed match still invalidates.
1610
+ # The chain above is Scope-total by construction (every helper returns its input scope or a
1611
+ # combinator result); the `||=` is a runtime no-op that pins the INFERRED type back to Scope for
1612
+ # the negative rules when a helper's return widens to `Scope?` under call-site binding (#524).
1613
+ post_scope ||= scope
1431
1614
  post_scope = post_scope.forget_match_globals if match_capable_call?(node)
1432
1615
  [call_type, post_scope]
1433
1616
  end
@@ -1466,12 +1649,14 @@ module Rigor
1466
1649
  seed = current_scope.class_ivars_for(class_name)
1467
1650
  return current_scope if seed.empty?
1468
1651
 
1469
- seed.reduce(current_scope) do |acc, (ivar_name, seed_type)|
1652
+ widened = current_scope
1653
+ seed.each do |ivar_name, seed_type|
1470
1654
  local_type = current_scope.ivar(ivar_name)
1471
- next acc if local_type.nil? || local_type == seed_type
1655
+ next if local_type.nil? || local_type == seed_type
1472
1656
 
1473
- acc.with_ivar(ivar_name, Type::Combinator.union(local_type, seed_type))
1657
+ widened = widened.with_ivar(ivar_name, Type::Combinator.union(local_type, seed_type))
1474
1658
  end
1659
+ widened
1475
1660
  end
1476
1661
 
1477
1662
  def intervening_call_candidate?(call_node)
@@ -1835,8 +2020,24 @@ module Rigor
1835
2020
  anonymous = AnonymousMetaClass.name_for(node, scope.source_path)
1836
2021
  return sub_eval(block, block_entry) if anonymous.nil?
1837
2022
 
1838
- sub_eval(block, block_entry.with_self_type(Type::Combinator.singleton_of(anonymous)),
1839
- class_context: [ClassFrame.new(name: anonymous, singleton: false)])
2023
+ enter_meta_class_body(block, block_entry, [ClassFrame.new(name: anonymous, singleton: false)])
2024
+ end
2025
+
2026
+ # Enters a meta-new `block` as the body of the class `class_context` names: `self_type` is that class's
2027
+ # singleton, so a `def` inside binds an instance method on it through the ordinary
2028
+ # {#self_type_for_method_body} route, while `block_entry` keeps the outer locals visible. Shared by the two
2029
+ # positions such a block is reached from — a statement-level call ({#evaluate_block_if_present}) and a
2030
+ # constant-write rvalue ({#eval_constant_write}) — so the two cannot drift on what a class body's entry is.
2031
+ #
2032
+ # Issue #652 — the body keeps this evaluator's `Module.nesting`. A BLOCK never pushes a cref, however
2033
+ # much `class_eval` semantics make it behave like a class body in every other respect, so
2034
+ # `K = Class.new do … end` written inside `module Outer` reads `Outer::LABEL` at runtime and MUST NOT
2035
+ # record a `Outer::K` entry: that is a rung Ruby's constant lookup does not have. The `class_context`
2036
+ # frame is still pushed — it is what a nested `def` registers its method under — which is exactly the
2037
+ # divergence that makes the chain a separate record rather than a view of the frame stack.
2038
+ def enter_meta_class_body(block, block_entry, class_context)
2039
+ entry = block_entry.with_self_type(self_type_for_class_body(class_context))
2040
+ sub_eval(block, stamp_nesting(entry, @lexical_nesting), class_context: class_context)
1840
2041
  end
1841
2042
 
1842
2043
  # Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
@@ -2165,42 +2366,12 @@ module Rigor
2165
2366
  # synthesise the union of the block's write types (which the current pass does not yet expose), we discard the
2166
2367
  # narrowed binding altogether. A future sub-phase MAY refine this to the union of the block's actual writes.
2167
2368
  def drop_captured_narrowing(block_node, base_scope)
2168
- names = captured_local_writes(block_node, base_scope)
2369
+ names = CapturedLocals.writes(block_node, base_scope)
2169
2370
  return base_scope if names.empty?
2170
2371
 
2171
2372
  names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
2172
2373
  end
2173
2374
 
2174
- # Names of outer locals the block body can REBIND, across every local-write form: plain `=`
2175
- # (`LocalVariableWriteNode`), the operator / `||=` / `&&=` compound forms, and a multi-assign target (`x, y = ...`
2176
- # → `LocalVariableTargetNode` under `MultiWriteNode`). Block-introduced names (parameters, numbered params,
2177
- # `;`-locals) and names not bound in the outer scope are excluded — a write to either is not a captured rebind of
2178
- # an outer variable.
2179
- LOCAL_WRITE_NODES = [
2180
- Prism::LocalVariableWriteNode,
2181
- Prism::LocalVariableOperatorWriteNode,
2182
- Prism::LocalVariableOrWriteNode,
2183
- Prism::LocalVariableAndWriteNode,
2184
- Prism::LocalVariableTargetNode
2185
- ].freeze
2186
- private_constant :LOCAL_WRITE_NODES
2187
-
2188
- def captured_local_writes(block_node, base_scope)
2189
- body = block_node.body
2190
- return [] if body.nil?
2191
-
2192
- introduced = block_introduced_locals(block_node)
2193
- outer_writes = []
2194
- Source::NodeWalker.each(body) do |descendant|
2195
- next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
2196
- next if introduced.include?(descendant.name)
2197
- next unless base_scope.locals.key?(descendant.name)
2198
-
2199
- outer_writes << descendant.name
2200
- end
2201
- outer_writes.uniq
2202
- end
2203
-
2204
2375
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
2205
2376
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
2206
2377
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
@@ -2208,13 +2379,13 @@ module Rigor
2208
2379
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2209
2380
  #
2210
2381
  # Fast path: a block writing no outer local leaves `post_scope` byte-identical (the overwhelming majority of
2211
- # blocks), so this costs one extra `captured_local_writes` walk and nothing else.
2382
+ # blocks), so this costs one extra `CapturedLocals.writes` walk and nothing else.
2212
2383
  def write_back_block_captures(call_node, post_scope)
2213
2384
  block = call_node.block
2214
2385
  return post_scope unless block.is_a?(Prism::BlockNode)
2215
2386
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
2216
2387
 
2217
- names = captured_local_writes(block, scope)
2388
+ names = CapturedLocals.writes(block, scope)
2218
2389
  return post_scope if names.empty?
2219
2390
 
2220
2391
  seed = names.to_h { |name| [name, scope.local(name)] }
@@ -2234,11 +2405,20 @@ module Rigor
2234
2405
  # collection's parameter, so `out = [0]; arr.each { |x| out << x }` types `out` as `Array[0 | Integer]` (sound)
2235
2406
  # rather than `Array[0]` (the B1 under-approximation: the runtime array is `[0, 1, 2, 3]`).
2236
2407
  #
2237
- # Pre-state is read from `post_scope` so a local that is BOTH rebound and content-mutated composes: the rebind
2238
- # fixpoint result feeds the content join. The block body is typed once for argument evidence; the floor is
2239
- # `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the sound empty-seed behaviour). Always sound only ever
2240
- # widens.
2241
- def content_writeback_block_captures(call_node, post_scope)
2408
+ # **Pre-state is read from `seed_scope` the scope as it stood BEFORE `widen_after_block` ran.** The widening
2409
+ # spells an empty `[]` as `Array[untyped]`, and read back after the fact that `untyped` cannot be told from a
2410
+ # DECLARED one: the join used to drop every seed `Dynamic` once the body contributed concrete evidence, which
2411
+ # made `out = []; xs.each { out << x }` read `Array[Integer]` — and closed a parameter declared `Array[untyped]`
2412
+ # to `Array[Integer]` on the same rule, so `a.first.upcase` drew `undefined method` on code the declaration
2413
+ # licenses (issue #586). Read before the widening, an empty literal contributes no element and a declared
2414
+ # gradual arm is just another seed arm the join keeps. The pre-widen scope still carries the slice-A rebind
2415
+ # write-back — so a local that is BOTH rebound and content-mutated composes as before — and every other
2416
+ # post-call effect applied ahead of the widening; the pre-CALL `scope` would carry neither. The loop seam makes
2417
+ # the same choice with `pre_body`; see {#loop_content_writeback}.
2418
+ #
2419
+ # The block body is typed once for argument evidence; the floor is `Array[Dynamic[top]]` /
2420
+ # `Hash[untyped, untyped]` (the sound empty-seed behaviour). Always sound — only ever widens.
2421
+ def content_writeback_block_captures(call_node, post_scope, seed_scope:)
2242
2422
  block = call_node.block
2243
2423
  return post_scope unless block.is_a?(Prism::BlockNode)
2244
2424
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
@@ -2251,7 +2431,7 @@ module Rigor
2251
2431
 
2252
2432
  entry = build_block_entry_scope(call_node, block)
2253
2433
  mutations.reduce(post_scope) do |acc, (name, calls)|
2254
- joined = join_content_for_local(name, calls, acc, entry)
2434
+ joined = join_content_for_local(name, calls, seed_scope, entry)
2255
2435
  joined.nil? ? acc : acc.with_local(name, joined)
2256
2436
  end
2257
2437
  end
@@ -2308,7 +2488,7 @@ module Rigor
2308
2488
  calls = []
2309
2489
  Source::NodeWalker.each(body) do |descendant|
2310
2490
  next unless descendant.is_a?(Prism::CallNode)
2311
- next unless MutationWidening::CONTENT_ADDERS.include?(descendant.name)
2491
+ next unless ContentJoin::CONTENT_ADDERS.include?(descendant.name)
2312
2492
 
2313
2493
  receiver = descendant.receiver
2314
2494
  next unless receiver.is_a?(Prism::LocalVariableReadNode)
@@ -2320,7 +2500,11 @@ module Rigor
2320
2500
  end
2321
2501
 
2322
2502
  # Joins content evidence for a memo / param given its pre-state and a list of mutator calls, dispatching Array vs
2323
- # Hash by the mutator set.
2503
+ # Hash by the PRE-STATE's own evidence. The mutator set never picks the arm on its own: `[]=` is legal on Array,
2504
+ # Hash, and countless index-writable classes, so an index-write against a receiver the engine cannot shape must
2505
+ # not synthesize a hash carrier — mail's `compose_codepoints` mutated its untyped Array param through
2506
+ # integer/range index writes and returned `Hash[Integer | Range, …]` to its caller (issue #553). Dynamic in,
2507
+ # Dynamic out: a shapeless pre-state falls through to `join_array_param`, which declines it.
2324
2508
  def join_content_for_param(calls, pre_state, block_entry)
2325
2509
  return nil if pre_state.nil?
2326
2510
 
@@ -2328,7 +2512,7 @@ module Rigor
2328
2512
  # String carries no element parameter; mutating `<<`/`concat` makes the constant value unsound (`s = "a"; s <<
2329
2513
  # x` → runtime `"a…"`), so widen to the nominal base. Sound — only widens.
2330
2514
  Type::Combinator.nominal_of("String")
2331
- elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
2515
+ elsif hashish?(pre_state)
2332
2516
  join_hash_param(calls, pre_state, block_entry)
2333
2517
  else
2334
2518
  join_array_param(calls, pre_state, block_entry)
@@ -2339,7 +2523,7 @@ module Rigor
2339
2523
  pairs = calls.flat_map { |c| hash_pair_types(c, block_entry) }
2340
2524
  return nil if pairs.empty? && !hashish?(pre_state)
2341
2525
 
2342
- MutationWidening.join_hash_content(pre_state, pairs)
2526
+ ContentJoin.join_hash_content(pre_state, pairs)
2343
2527
  end
2344
2528
 
2345
2529
  def join_array_param(calls, pre_state, block_entry)
@@ -2350,9 +2534,9 @@ module Rigor
2350
2534
  # array-arity forget already widened the binding; contribute nothing.
2351
2535
  next [] if index_write?(c)
2352
2536
 
2353
- MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
2537
+ ContentJoin.array_added_elements(c.name, content_arg_types(c, block_entry))
2354
2538
  end
2355
- MutationWidening.join_array_content(pre_state, added)
2539
+ ContentJoin.join_array_content(pre_state, added)
2356
2540
  end
2357
2541
 
2358
2542
  # Walks the block body for content-mutator calls (`<<`, `push`, `[]=`, …) whose receiver is a captured outer local
@@ -2381,7 +2565,7 @@ module Rigor
2381
2565
  # `[receiver_name, node]` when `node` is a content mutation whose receiver is a local variable satisfying `accept`
2382
2566
  # (depth predicate), else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the index-write node forms.
2383
2567
  def content_mutation_target(node)
2384
- is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
2568
+ is_call_mutator = node.is_a?(Prism::CallNode) && ContentJoin::CONTENT_ADDERS.include?(node.name)
2385
2569
  return [nil, nil] unless is_call_mutator || index_write?(node)
2386
2570
 
2387
2571
  receiver = node.receiver
@@ -2398,21 +2582,19 @@ module Rigor
2398
2582
  join_content_for_param(calls, post_scope.local(name), block_entry)
2399
2583
  end
2400
2584
 
2401
- def hash_mutations?(calls)
2402
- calls.any? do |c|
2403
- index_write?(c) || (c.is_a?(Prism::CallNode) && MutationWidening::HASH_CONTENT_ADDERS.include?(c.name))
2404
- end
2405
- end
2406
-
2407
2585
  def index_write?(node)
2408
2586
  INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
2409
2587
  end
2410
2588
 
2589
+ # A `Difference` (`non-empty-array[T]`) counts as its base. The seams read their seed from before the mutation
2590
+ # widening ran, so they meet the refinement carrier where they used to meet the `Array[T]` it widens to;
2591
+ # declining it would leave the continuation on that widened base with every appended arm missing.
2411
2592
  def arrayish?(type)
2412
2593
  case type
2413
2594
  when Type::Tuple then true
2414
2595
  when Type::Nominal then type.class_name == "Array"
2415
2596
  when Type::Union then type.members.any? { |m| arrayish?(m) }
2597
+ when Type::Difference then arrayish?(type.base)
2416
2598
  else false
2417
2599
  end
2418
2600
  end
@@ -2422,6 +2604,7 @@ module Rigor
2422
2604
  when Type::HashShape then true
2423
2605
  when Type::Nominal then type.class_name == "Hash"
2424
2606
  when Type::Union then type.members.any? { |m| hashish?(m) }
2607
+ when Type::Difference then hashish?(type.base)
2425
2608
  else false
2426
2609
  end
2427
2610
  end
@@ -2482,16 +2665,6 @@ module Rigor
2482
2665
  names.to_h { |name| [name, exit_scope.local(name)] }
2483
2666
  end
2484
2667
 
2485
- # Names introduced by the block itself (parameters, numbered parameters via `BlockParameterBinder`, plus explicit
2486
- # `;`-prefixed block-locals on `BlockParametersNode`). Writes to these names are local to the block and MUST NOT
2487
- # be treated as captured rebinds of an outer local.
2488
- def block_introduced_locals(block_node)
2489
- introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
2490
- params_root = block_node.parameters
2491
- params_root.locals.each { |loc| introduced << loc.name } if params_root.is_a?(Prism::BlockParametersNode)
2492
- introduced
2493
- end
2494
-
2495
2668
  # `Prism::BlockNode` is reached through {#eval_call}; the handler runs the body under `scope`, which the caller
2496
2669
  # has already augmented with the block's parameter bindings. Effects do not leak past the block (the outer
2497
2670
  # eval_call returns the caller's scope unchanged), but the body's local writes are threaded through subsequent
@@ -2554,7 +2727,7 @@ module Rigor
2554
2727
 
2555
2728
  # ----- def/class helpers -----
2556
2729
 
2557
- def eval_class_body(node, new_context)
2730
+ def eval_class_body(node, new_context, new_nesting = @lexical_nesting)
2558
2731
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2559
2732
 
2560
2733
  # Class/module bodies run in a fresh scope: the outer scope's locals are NOT visible inside `class Foo; ...
@@ -2564,7 +2737,8 @@ module Rigor
2564
2737
  fresh = build_fresh_body_scope
2565
2738
  body_self = self_type_for_class_body(new_context)
2566
2739
  fresh = fresh.with_self_type(body_self) if body_self
2567
- sub_eval(node.body, fresh, class_context: new_context)
2740
+ fresh = stamp_nesting(fresh, new_nesting)
2741
+ sub_eval(node.body, fresh, class_context: new_context, lexical_nesting: new_nesting)
2568
2742
  end
2569
2743
 
2570
2744
  def build_method_entry_scope(def_node) # rubocop:disable Metrics/AbcSize
@@ -2591,6 +2765,9 @@ module Rigor
2591
2765
  fresh = build_fresh_body_scope
2592
2766
  body_self = self_type_for_method_body(singleton: singleton)
2593
2767
  fresh = fresh.with_self_type(body_self) if body_self
2768
+ # A `def` opens no new `Module.nesting` entry — the body resolves constants against the chain of the
2769
+ # declaration that encloses it, which is what the evaluator is already carrying (#652).
2770
+ fresh = stamp_nesting(fresh, @lexical_nesting)
2594
2771
  fresh = seed_instance_ivars(fresh, singleton: singleton)
2595
2772
  fresh = seed_class_cvars(fresh)
2596
2773
  fresh = seed_program_globals(fresh)
@@ -2806,6 +2983,14 @@ module Rigor
2806
2983
  end
2807
2984
  end
2808
2985
 
2986
+ # Stamps a recorded chain onto a body-entry scope. An empty chain (a top-level body) and an unknown one
2987
+ # are both left unstamped, so `Reflection.lexical_nesting_chain` keeps its peel fallback for them.
2988
+ def stamp_nesting(body_scope, chain)
2989
+ return body_scope if chain.nil? || chain.empty?
2990
+
2991
+ body_scope.with_lexical_nesting(chain)
2992
+ end
2993
+
2809
2994
  # The qualified name of the immediately-enclosing class (joining every nested `ClassFrame` with `::`). Returns
2810
2995
  # `nil` for a top-level def with no enclosing class, which routes the parameter binder past RBS lookup.
2811
2996
  def current_class_path
@@ -2859,12 +3044,13 @@ module Rigor
2859
3044
  end
2860
3045
  end
2861
3046
 
2862
- def sub_eval(node, with_scope, class_context: @class_context)
3047
+ def sub_eval(node, with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting)
2863
3048
  StatementEvaluator.new(
2864
3049
  scope: with_scope,
2865
3050
  tracer: tracer,
2866
3051
  on_enter: @on_enter,
2867
3052
  class_context: class_context,
3053
+ lexical_nesting: lexical_nesting,
2868
3054
  converged_loop_recording: @converged_loop_recording
2869
3055
  ).evaluate(node)
2870
3056
  end
@@ -3119,6 +3305,5 @@ module Rigor
3119
3305
  merged.to_a
3120
3306
  end
3121
3307
  end
3122
- # rubocop:enable Metrics/ClassLength
3123
3308
  end
3124
3309
  end