rigortype 0.4.5 → 0.4.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 (452) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/gem_overlay/activesupport/core_ext.rbs +16 -13
  4. data/data/vendored_gem_sigs/bundler/bundler.rbs +1 -1
  5. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +4 -3
  6. data/docs/handbook/01-getting-started.md +5 -2
  7. data/docs/handbook/11-sig-gen.md +61 -5
  8. data/docs/handbook/appendix-phpstan.md +4 -4
  9. data/docs/manual/02-cli-reference.md +192 -27
  10. data/docs/manual/03-configuration.md +38 -4
  11. data/docs/manual/04-diagnostics.md +1 -1
  12. data/docs/manual/06-baseline.md +6 -4
  13. data/docs/manual/09-editor-integration.md +13 -2
  14. data/docs/manual/10-mcp-server.md +47 -3
  15. data/docs/manual/12-caching.md +9 -1
  16. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +49 -11
  18. data/docs/manual/plugins/rigor-activesupport-core-ext.md +20 -1
  19. data/docs/manual/plugins/rigor-devise.md +15 -0
  20. data/docs/manual/plugins/rigor-rspec.md +2 -1
  21. data/lib/rigor/analysis/baseline.rb +24 -27
  22. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
  24. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
  25. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +4 -4
  26. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +105 -12
  28. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +143 -15
  29. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -1
  30. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
  31. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +1 -1
  32. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  33. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  34. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  35. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
  36. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  37. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -1
  38. data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
  39. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
  40. data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
  41. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +53 -2
  42. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  43. data/lib/rigor/analysis/check_rules.rb +217 -52
  44. data/lib/rigor/analysis/crash_signature.rb +2 -2
  45. data/lib/rigor/analysis/dependency_recorder.rb +15 -4
  46. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
  47. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  48. data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
  49. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  50. data/lib/rigor/analysis/edit_replay.rb +154 -0
  51. data/lib/rigor/analysis/fact_store.rb +3 -3
  52. data/lib/rigor/analysis/incremental.rb +37 -0
  53. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -2
  54. data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
  55. data/lib/rigor/analysis/incremental_session.rb +202 -43
  56. data/lib/rigor/analysis/path_expansion.rb +41 -0
  57. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  58. data/lib/rigor/analysis/rule_catalog.rb +7 -3
  59. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  60. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  61. data/lib/rigor/analysis/run_stats.rb +2 -2
  62. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +8 -4
  63. data/lib/rigor/analysis/runner/declaration_position.rb +1 -1
  64. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +204 -96
  65. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +157 -320
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +48 -6
  68. data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
  70. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  71. data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
  72. data/lib/rigor/analysis/runner.rb +233 -120
  73. data/lib/rigor/analysis/self_call_resolution_recorder.rb +1 -1
  74. data/lib/rigor/analysis/template_unit_positions.rb +2 -3
  75. data/lib/rigor/analysis/template_units.rb +9 -1
  76. data/lib/rigor/analysis/worker_session.rb +38 -33
  77. data/lib/rigor/bleeding_edge.rb +3 -6
  78. data/lib/rigor/builtins/imported_refinements.rb +6 -8
  79. data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
  80. data/lib/rigor/builtins/regex_refinement.rb +4 -6
  81. data/lib/rigor/cache/descriptor.rb +6 -5
  82. data/lib/rigor/cache/incremental_snapshot.rb +60 -18
  83. data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
  84. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  85. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
  86. data/lib/rigor/cache/rbs_constant_table.rb +1 -1
  87. data/lib/rigor/cache/rbs_descriptor.rb +54 -3
  88. data/lib/rigor/cache/rbs_environment.rb +4 -1
  89. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
  90. data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
  91. data/lib/rigor/cache/store.rb +11 -10
  92. data/lib/rigor/ci_detector.rb +1 -1
  93. data/lib/rigor/cli/annotate_command.rb +24 -19
  94. data/lib/rigor/cli/baseline_command.rb +145 -55
  95. data/lib/rigor/cli/catalogue.rb +256 -0
  96. data/lib/rigor/cli/check_command.rb +188 -93
  97. data/lib/rigor/cli/check_runner_factory.rb +2 -2
  98. data/lib/rigor/cli/command.rb +15 -1
  99. data/lib/rigor/cli/coverage_command.rb +21 -8
  100. data/lib/rigor/cli/coverage_mutation.rb +5 -7
  101. data/lib/rigor/cli/coverage_renderer.rb +2 -1
  102. data/lib/rigor/cli/coverage_scan.rb +3 -5
  103. data/lib/rigor/cli/diff_command.rb +27 -21
  104. data/lib/rigor/cli/docs_command.rb +1 -2
  105. data/lib/rigor/cli/doctor_command.rb +12 -12
  106. data/lib/rigor/cli/effects_command.rb +50 -40
  107. data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
  108. data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
  109. data/lib/rigor/cli/effects_renderer.rb +2 -1
  110. data/lib/rigor/cli/effects_snapshot_command.rb +18 -17
  111. data/lib/rigor/cli/explain_command.rb +18 -8
  112. data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
  113. data/lib/rigor/cli/json_document.rb +41 -0
  114. data/lib/rigor/cli/lens_command.rb +17 -13
  115. data/lib/rigor/cli/lsp_command.rb +250 -23
  116. data/lib/rigor/cli/mcp_command.rb +14 -8
  117. data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
  118. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
  119. data/lib/rigor/cli/options.rb +60 -4
  120. data/lib/rigor/cli/plugin_command.rb +93 -46
  121. data/lib/rigor/cli/plugins_command.rb +22 -18
  122. data/lib/rigor/cli/plugins_renderer.rb +34 -8
  123. data/lib/rigor/cli/protection_fork_scan.rb +2 -2
  124. data/lib/rigor/cli/protection_renderer.rb +2 -1
  125. data/lib/rigor/cli/show_bleedingedge_command.rb +23 -16
  126. data/lib/rigor/cli/sig_gen_command.rb +130 -34
  127. data/lib/rigor/cli/skill_command.rb +32 -3
  128. data/lib/rigor/cli/skill_deep_probe.rb +1 -1
  129. data/lib/rigor/cli/skill_describe.rb +1 -1
  130. data/lib/rigor/cli/trace_command.rb +14 -9
  131. data/lib/rigor/cli/triage_command.rb +10 -11
  132. data/lib/rigor/cli/triage_renderer.rb +2 -1
  133. data/lib/rigor/cli/type_of_command.rb +18 -12
  134. data/lib/rigor/cli/type_of_project_scope.rb +24 -0
  135. data/lib/rigor/cli/type_of_renderer.rb +4 -1
  136. data/lib/rigor/cli/type_scan_command.rb +14 -9
  137. data/lib/rigor/cli/type_scan_renderer.rb +2 -1
  138. data/lib/rigor/cli/unused_command.rb +27 -17
  139. data/lib/rigor/cli/upgrade_command.rb +5 -0
  140. data/lib/rigor/cli.rb +209 -54
  141. data/lib/rigor/config_audit.rb +17 -6
  142. data/lib/rigor/configuration/severity_profile.rb +5 -5
  143. data/lib/rigor/configuration.rb +86 -34
  144. data/lib/rigor/effects/definition_context.rb +0 -2
  145. data/lib/rigor/effects/envelope.rb +1 -1
  146. data/lib/rigor/effects/framework_units.rb +1 -1
  147. data/lib/rigor/effects/mutation_classifier.rb +4 -7
  148. data/lib/rigor/effects/propagator.rb +1 -1
  149. data/lib/rigor/effects/registry.rb +2 -2
  150. data/lib/rigor/effects/snapshot.rb +1 -1
  151. data/lib/rigor/effects/taint_cause.rb +0 -2
  152. data/lib/rigor/effects/unit_scan.rb +9 -13
  153. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -9
  154. data/lib/rigor/environment/class_registry.rb +3 -3
  155. data/lib/rigor/environment/core_root.rb +77 -0
  156. data/lib/rigor/environment/default_libraries.rb +1 -1
  157. data/lib/rigor/environment/deferred_signature_plan.rb +283 -0
  158. data/lib/rigor/environment/gated_signature_guard.rb +1 -1
  159. data/lib/rigor/environment/gated_signature_plan.rb +5 -2
  160. data/lib/rigor/environment/lockfile_resolver.rb +28 -12
  161. data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
  162. data/lib/rigor/environment/member_consistency.rb +56 -7
  163. data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
  164. data/lib/rigor/environment/project_path_gems.rb +124 -0
  165. data/lib/rigor/environment/rbs_hierarchy.rb +6 -15
  166. data/lib/rigor/environment/rbs_loader.rb +592 -70
  167. data/lib/rigor/environment/reflection.rb +7 -7
  168. data/lib/rigor/environment/required_features.rb +136 -32
  169. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  170. data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
  171. data/lib/rigor/environment.rb +44 -11
  172. data/lib/rigor/flow_contribution/merger.rb +4 -4
  173. data/lib/rigor/inference/acceptance.rb +93 -32
  174. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  175. data/lib/rigor/inference/block_run_count.rb +178 -0
  176. data/lib/rigor/inference/budget_trace.rb +8 -2
  177. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
  178. data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
  179. data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
  180. data/lib/rigor/inference/callee_mutation_chain.rb +126 -0
  181. data/lib/rigor/inference/callee_write_census.rb +815 -0
  182. data/lib/rigor/inference/construction_escape.rb +217 -0
  183. data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
  184. data/lib/rigor/inference/deep_erasure.rb +170 -0
  185. data/lib/rigor/inference/def_handle.rb +1 -1
  186. data/lib/rigor/inference/def_node_resolver.rb +1 -1
  187. data/lib/rigor/inference/def_return_typer.rb +204 -12
  188. data/lib/rigor/inference/definer_resolution.rb +21 -5
  189. data/lib/rigor/inference/definers_index.rb +106 -0
  190. data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
  191. data/lib/rigor/inference/element_read_widening.rb +49 -3
  192. data/lib/rigor/inference/error_info.rb +17 -3
  193. data/lib/rigor/inference/expression_typer.rb +173 -57
  194. data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
  195. data/lib/rigor/inference/global_write_census.rb +110 -1
  196. data/lib/rigor/inference/global_writer_reach.rb +260 -0
  197. data/lib/rigor/inference/hkt_body.rb +1 -2
  198. data/lib/rigor/inference/hkt_body_parser.rb +3 -5
  199. data/lib/rigor/inference/hkt_reducer.rb +2 -2
  200. data/lib/rigor/inference/hkt_registry.rb +3 -5
  201. data/lib/rigor/inference/indexed_narrowing.rb +4 -7
  202. data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
  203. data/lib/rigor/inference/ivar_writer_census.rb +246 -0
  204. data/lib/rigor/inference/key_presence_guard.rb +2 -1
  205. data/lib/rigor/inference/loop_value_record.rb +57 -0
  206. data/lib/rigor/inference/match_rebinding/frame.rb +65 -0
  207. data/lib/rigor/inference/match_rebinding/yielding_match.rb +45 -0
  208. data/lib/rigor/inference/match_rebinding.rb +11 -1
  209. data/lib/rigor/inference/memo_audit.rb +55 -0
  210. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
  211. data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
  212. data/lib/rigor/inference/method_dispatcher/block_folding.rb +1 -1
  213. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +113 -12
  214. data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
  215. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
  216. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -3
  217. data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
  218. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +39 -27
  219. data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
  220. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +203 -17
  221. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
  222. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
  223. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +2 -2
  224. data/lib/rigor/inference/method_dispatcher/set_folding.rb +59 -24
  225. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +83 -20
  226. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
  227. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
  228. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
  229. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
  230. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  231. data/lib/rigor/inference/method_dispatcher.rb +109 -29
  232. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  233. data/lib/rigor/inference/module_function_state.rb +23 -1
  234. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  235. data/lib/rigor/inference/mutation_rejoin.rb +240 -4
  236. data/lib/rigor/inference/mutation_widening.rb +162 -29
  237. data/lib/rigor/inference/narrowing.rb +113 -38
  238. data/lib/rigor/inference/object_mixins.rb +13 -1
  239. data/lib/rigor/inference/optimistic_origin.rb +1 -1
  240. data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
  241. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  242. data/lib/rigor/inference/pre_eval_constants.rb +1 -1
  243. data/lib/rigor/inference/project_method_ownership.rb +6 -3
  244. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  245. data/lib/rigor/inference/project_yielder.rb +122 -0
  246. data/lib/rigor/inference/receiver_alias.rb +20 -0
  247. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +3 -1
  248. data/lib/rigor/inference/refined_dispatch/own_method.rb +2 -1
  249. data/lib/rigor/inference/remover_holder_scan.rb +233 -0
  250. data/lib/rigor/inference/remover_narrowing.rb +224 -0
  251. data/lib/rigor/inference/rewrite_mutation.rb +53 -2
  252. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -1
  253. data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
  254. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  255. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +205 -0
  256. data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
  257. data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
  258. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +135 -0
  259. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  260. data/lib/rigor/inference/scope_indexer.rb +614 -146
  261. data/lib/rigor/inference/self_call_ivar_writes.rb +101 -17
  262. data/lib/rigor/inference/set_mutation.rb +189 -0
  263. data/lib/rigor/inference/singleton_hook_decline.rb +1 -2
  264. data/lib/rigor/inference/statement_evaluator.rb +3036 -278
  265. data/lib/rigor/inference/struct_fold_safety.rb +4 -6
  266. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  267. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  268. data/lib/rigor/inference/synthetic_method_scanner.rb +73 -14
  269. data/lib/rigor/inference/toplevel_def_census.rb +59 -0
  270. data/lib/rigor/inference/unknown_store_widening.rb +103 -4
  271. data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
  272. data/lib/rigor/inference/void_tail_summary.rb +2 -1
  273. data/lib/rigor/language_server/buffer_table.rb +9 -3
  274. data/lib/rigor/language_server/completion_provider.rb +26 -20
  275. data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
  276. data/lib/rigor/language_server/diagnostic_publisher.rb +28 -9
  277. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  278. data/lib/rigor/language_server/folding_range_provider.rb +1 -2
  279. data/lib/rigor/language_server/hover_provider.rb +1 -7
  280. data/lib/rigor/language_server/hover_renderer.rb +6 -8
  281. data/lib/rigor/language_server/project_context.rb +98 -4
  282. data/lib/rigor/language_server/publish_batcher.rb +3 -4
  283. data/lib/rigor/language_server/selection_range_provider.rb +2 -4
  284. data/lib/rigor/language_server/server.rb +12 -6
  285. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  286. data/lib/rigor/lens/plugins.rb +1 -1
  287. data/lib/rigor/mcp/server.rb +82 -3
  288. data/lib/rigor/mcp.rb +1 -1
  289. data/lib/rigor/plugin/access_denied_error.rb +1 -2
  290. data/lib/rigor/plugin/base.rb +26 -11
  291. data/lib/rigor/plugin/bundled_catalog.rb +1 -12
  292. data/lib/rigor/plugin/io_boundary.rb +1 -1
  293. data/lib/rigor/plugin/loader.rb +25 -8
  294. data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
  295. data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
  296. data/lib/rigor/plugin/manifest.rb +38 -16
  297. data/lib/rigor/plugin/registry.rb +41 -3
  298. data/lib/rigor/plugin/services.rb +2 -3
  299. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
  300. data/lib/rigor/plugin/trust_policy.rb +2 -2
  301. data/lib/rigor/plugin/type_node_resolver.rb +2 -4
  302. data/lib/rigor/plugin.rb +6 -8
  303. data/lib/rigor/plugin_gap_advisory.rb +5 -1
  304. data/lib/rigor/project_environment.rb +16 -2
  305. data/lib/rigor/protection/analysis_guard.rb +1 -1
  306. data/lib/rigor/protection/discovery_seed.rb +96 -2
  307. data/lib/rigor/protection/mutator.rb +1 -1
  308. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  309. data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
  310. data/lib/rigor/rbs_extended/reporter.rb +27 -3
  311. data/lib/rigor/rbs_extended.rb +2 -3
  312. data/lib/rigor/reflection/constant_ancestors.rb +5 -3
  313. data/lib/rigor/rooted_glob.rb +25 -2
  314. data/lib/rigor/run_scoped_slots.rb +38 -0
  315. data/lib/rigor/runtime/jit.rb +1 -1
  316. data/lib/rigor/scope/discovery_index.rb +19 -6
  317. data/lib/rigor/scope/resolution_chain.rb +3 -1
  318. data/lib/rigor/scope.rb +379 -36
  319. data/lib/rigor/sig_gen/classification.rb +4 -0
  320. data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
  321. data/lib/rigor/sig_gen/generator.rb +668 -84
  322. data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
  323. data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
  324. data/lib/rigor/sig_gen/layout_index.rb +3 -3
  325. data/lib/rigor/sig_gen/method_candidate.rb +23 -4
  326. data/lib/rigor/sig_gen/observation_collector.rb +7 -6
  327. data/lib/rigor/sig_gen/observed_call.rb +1 -1
  328. data/lib/rigor/sig_gen/path_mapper.rb +6 -0
  329. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  330. data/lib/rigor/sig_gen/renderer.rb +21 -12
  331. data/lib/rigor/sig_gen/skip_reason_catalog.rb +15 -2
  332. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  333. data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
  334. data/lib/rigor/sig_gen/write_reporter.rb +63 -14
  335. data/lib/rigor/sig_gen/write_result.rb +23 -12
  336. data/lib/rigor/sig_gen/writer.rb +217 -37
  337. data/lib/rigor/type/acceptance_router.rb +2 -2
  338. data/lib/rigor/type/accepts_result.rb +1 -1
  339. data/lib/rigor/type/app.rb +4 -6
  340. data/lib/rigor/type/combinator.rb +13 -16
  341. data/lib/rigor/type/constant.rb +48 -7
  342. data/lib/rigor/type/hash_shape.rb +26 -40
  343. data/lib/rigor/type/struct_instance.rb +3 -2
  344. data/lib/rigor/type/union.rb +1 -1
  345. data/lib/rigor/type_node/identifier.rb +1 -2
  346. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  347. data/lib/rigor/type_node/string_literal.rb +1 -2
  348. data/lib/rigor/type_node/symbol_literal.rb +1 -2
  349. data/lib/rigor/type_node/union.rb +3 -6
  350. data/lib/rigor/value_semantics.rb +1 -10
  351. data/lib/rigor/version.rb +1 -1
  352. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
  353. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
  354. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
  355. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +89 -70
  356. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
  357. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +25 -4
  358. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
  359. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
  360. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
  361. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
  362. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  363. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +79 -19
  364. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  365. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +6 -3
  366. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
  367. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
  368. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
  369. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
  370. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +2 -3
  371. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +0 -1
  372. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +5 -4
  373. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
  374. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
  375. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +7 -12
  376. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
  377. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
  378. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
  379. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +19 -6
  380. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +26 -13
  381. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
  382. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  383. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
  384. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
  385. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
  386. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  387. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
  388. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
  389. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
  390. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  391. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
  392. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
  393. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
  394. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -5
  395. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
  396. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
  397. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
  398. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
  399. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -2
  400. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
  401. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  402. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
  403. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
  404. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
  405. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +1 -3
  406. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
  407. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
  408. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
  409. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  410. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
  411. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  412. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
  413. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
  414. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
  415. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +5 -2
  416. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
  417. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +1 -1
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +17 -21
  421. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
  422. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +3 -2
  423. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
  424. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  425. data/sig/rigor/analysis/baseline.rbs +0 -2
  426. data/sig/rigor/analysis/fact_store.rbs +1 -0
  427. data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
  428. data/sig/rigor/cli/diff_command.rbs +1 -1
  429. data/sig/rigor/cli/explain_command.rbs +1 -1
  430. data/sig/rigor/effects/file_collection.rbs +5 -6
  431. data/sig/rigor/effects/registry.rbs +8 -6
  432. data/sig/rigor/effects/summary.rbs +6 -5
  433. data/sig/rigor/environment.rbs +5 -3
  434. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  435. data/sig/rigor/inference.rbs +10 -1
  436. data/sig/rigor/plugin/additional_initializer.rbs +0 -5
  437. data/sig/rigor/plugin/base.rbs +1 -0
  438. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  439. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  440. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  441. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  442. data/sig/rigor/plugin/io_boundary.rbs +6 -5
  443. data/sig/rigor/plugin/manifest.rbs +16 -18
  444. data/sig/rigor/plugin/protocol_contract.rbs +6 -9
  445. data/sig/rigor/plugin/registry.rbs +1 -1
  446. data/sig/rigor/scope.rbs +51 -50
  447. data/sig/rigor/type.rbs +0 -6
  448. data/skills/rigor-plugin-author/SKILL.md +4 -4
  449. data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
  450. data/skills/rigor-plugin-review/SKILL.md +3 -3
  451. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +12 -1
  452. metadata +55 -3
@@ -15,6 +15,7 @@ require_relative "../cache/file_digest"
15
15
  require_relative "../analysis/check_rules/published_constant_guard"
16
16
  require_relative "anonymous_meta_class"
17
17
  require_relative "def_handle"
18
+ require_relative "definers_index"
18
19
  require_relative "fresh_frame_blocks"
19
20
  require_relative "global_write_census"
20
21
  require_relative "guard_rebinding"
@@ -43,6 +44,11 @@ require_relative "scope_indexer/layered_def_nestings"
43
44
  require_relative "scope_indexer/mixin_accumulator"
44
45
  require_relative "scope_indexer/certainty"
45
46
  require_relative "scope_indexer/member_layouts_collector"
47
+ require_relative "scope_indexer/declared_ivar_types"
48
+ require_relative "scope_indexer/declared_param_writes"
49
+ require_relative "scope_indexer/constant_ivar_value"
50
+ require_relative "scope_indexer/ivar_contributions"
51
+ require_relative "scope_indexer/empty_seed"
46
52
 
47
53
  module Rigor
48
54
  module Inference
@@ -65,8 +71,8 @@ module Rigor
65
71
  # ```
66
72
  #
67
73
  # Nodes that are not part of the program subtree (e.g. synthesised virtual nodes that the caller looks up after the
68
- # fact) yield the `default_scope`. The returned Hash is mutable in principle but callers MUST treat it as read-only;
69
- # the indexer itself never exposes a way to update it past construction.
74
+ # fact) yield the seeded `default_scope`. The returned Hash is mutable in principle but callers MUST treat it as
75
+ # read-only; the indexer itself never exposes a way to update it past construction.
70
76
  # rubocop:disable-next Metrics/ModuleLength
71
77
  module ScopeIndexer
72
78
  # Issue #644 — the census descriptor for a constant write that publishes no value: an unfoldable or
@@ -107,12 +113,24 @@ module Rigor
107
113
  # `Dynamic[top]`. Off for every other reader, so parameter inference
108
114
  # and sig-gen observe the clause's plain entry.
109
115
  # @return identity-comparing
110
- # table whose default value is `default_scope`.
116
+ # table whose default value is the seeded `default_scope`.
111
117
  # The no-op re-anchoring table (#722 residue 2): shared so the overwhelmingly common
112
118
  # nothing-to-re-anchor walk allocates none.
113
119
  EMPTY_RENAMES = {}.freeze
114
120
 
115
- def index(root, default_scope:, converged_loop_recording: false, widen_clauses: false) # rubocop:disable Metrics/AbcSize
121
+ # No path whose contributions are all `possible` (issue #2021), the cross-file walks' default.
122
+ EMPTY_PATH_SET = Set.new.freeze
123
+ # The {IvarWriterCensus} entry kinds the project pre-pass tags with the file that records them.
124
+ PATH_TAGGED_KINDS = %i[own_write own_write_type].to_set.freeze
125
+
126
+ # `declared_ivar_types:` — whether an RBS-declared ivar type replaces the census's join of the writes (#1406).
127
+ # `rigor sig-gen` passes false: it proposes what the code says, and reading a declaration back would make a row
128
+ # count as generated only because it is declared.
129
+ # `ivar_writer_scope:` — the scope whose project tables the #1154 declared-parameter credit reads to find the
130
+ # ivar writers outside this file ({DeclaredParamWrites}); defaults to `default_scope`. `rigor sig-gen` indexes
131
+ # each file under an unseeded scope, so it passes one carrying the project's tables.
132
+ def index(root, default_scope:, converged_loop_recording: false, widen_clauses: false, # rubocop:disable Metrics/AbcSize
133
+ declared_ivar_types: true, ivar_writer_scope: nil)
116
134
  # Slice A-declarations. Build the declaration overrides first so every scope handed to the StatementEvaluator
117
135
  # already carries the table; structural sharing through `Scope#with_local` / `#with_fact` / `#with_self_type`
118
136
  # propagates it across every derived scope.
@@ -134,7 +152,9 @@ module Rigor
134
152
 
135
153
  # Slice 7 phase 2. Pre-pass over every class/module body to collect the per-class ivar accumulator. Seeded after
136
154
  # declared_types so the rvalue typer in the pre-pass can see declaration overrides.
137
- class_ivars = build_class_ivar_index(root, seeded_scope)
155
+ class_ivars = DeclaredParamWrites.with_writer_scope(ivar_writer_scope) do
156
+ build_class_ivar_index(root, seeded_scope, declared_ivar_types: declared_ivar_types)
157
+ end
138
158
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
139
159
 
140
160
  # Slice 7 phase 6. Same pre-pass shape for cvars (per class) and globals (program-wide). Globals are also
@@ -197,19 +217,31 @@ module Rigor
197
217
  # share.
198
218
  seeded_scope = seed_struct_fold_safe(seeded_scope, root).with_match_frame(root)
199
219
 
200
- on_enter = ->(node, scope) { table[node] = scope }
201
- StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
202
- converged_loop_recording: converged_loop_recording,
203
- widen_clauses: widen_clauses).evaluate(root)
220
+ evaluate = recording_evaluator(table, converged_loop_recording, widen_clauses)
221
+ evaluate.call(root, seeded_scope)
204
222
 
205
- propagate(root, table, seeded_scope)
223
+ with_unentered_evaluator(evaluate) { propagate(root, table, seeded_scope) }
206
224
  table
207
225
  end
208
226
 
227
+ # The `(node, scope, excluded = nil) ->` evaluation that records every node it visits into `table`, except the
228
+ # nodes in `excluded`: the file's own pass, and each construct {#evaluate_unentered_flow} evaluates in the walk
229
+ # after it.
230
+ def recording_evaluator(table, converged_loop_recording, widen_clauses)
231
+ lambda do |node, scope, excluded = nil|
232
+ on_enter =
233
+ if excluded.nil? || excluded.empty? then ->(visited, at) { table[visited] = at }
234
+ else ->(visited, at) { table[visited] = at unless excluded.include?(visited) }
235
+ end
236
+ StatementEvaluator.new(scope: scope, on_enter: on_enter, converged_loop_recording: converged_loop_recording,
237
+ widen_clauses: widen_clauses).evaluate(node)
238
+ end
239
+ end
240
+
209
241
  # Runs the combined methods/def-nodes descent (one walk of the file), seeds the discovered-methods existence table
210
- # onto `seeded_scope` (merged UNDER the cross-file pre-pass seed `default_scope` carries), and returns `[scope,
211
- # file_def_nodes, file_envelopes]` so the caller can thread the def-node and issue #992 envelope tables into
212
- # {#merge_project_method_indexes} without walking the file a second time.
242
+ # onto `seeded_scope` (merged UNDER the cross-file pre-pass seed `default_scope` carries), and returns
243
+ # `[scope, [file_def_nodes, file_envelopes, file_siblings]]` so the caller can thread the def-node and issue #992
244
+ # envelope tables into {#merge_project_method_indexes} without walking the file a second time.
213
245
  #
214
246
  # ADR-119 WD3 — the file's `possible_discovered_methods` folds over the seed's by {#fold_possible_methods}, so a
215
247
  # name the file defines certainly is no longer possible and a name only the file's possible contribution
@@ -280,8 +312,8 @@ module Rigor
280
312
  siblings = per_file_siblings(seeded_scope.discovery.sibling_tables, file_tables[0], file_tables[2],
281
313
  file_singleton, file_visibilities)
282
314
  extends, methods_table, unpositioned =
283
- merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope, siblings,
284
- mixin_unpositioned)
315
+ merge_and_fold_extends(default_scope, root, [def_nodes, file_tables[0]],
316
+ [singleton_def_nodes, file_singleton[0]], seeded_scope, siblings, mixin_unpositioned)
285
317
 
286
318
  seeded_scope.with_discovery(
287
319
  seeded_scope.discovery.with(
@@ -395,13 +427,23 @@ module Rigor
395
427
  # ADR-119 WD1 — the unpositioned union (the seed's, the mixin walks' and this file's extend side) is settled
396
428
  # first, because the fold reads it to tell a certain extend edge from a listed one. `siblings` is written by
397
429
  # the fold.
398
- def merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope, siblings,
430
+ # `def_pair` and `singleton_pair` are `[merged table, this file's own]`; the merged tables are frozen once the
431
+ # fold, their last in-place writer, is done ({#freeze_def_tables}).
432
+ def merge_and_fold_extends(default_scope, root, def_pair, singleton_pair, seeded_scope, siblings,
399
433
  mixin_unpositioned)
434
+ def_nodes = def_pair.first
435
+ singleton_def_nodes = singleton_pair.first
400
436
  file_extends, extends, extend_unpositioned = merge_extend_tables(default_scope, root)
401
437
  unpositioned = union_unpositioned(default_scope.discovery.unpositioned_mixins, *mixin_unpositioned,
402
438
  extend_unpositioned)
403
439
  methods_table = fold_per_file_extends(file_extends, def_nodes, singleton_def_nodes, seeded_scope, siblings,
404
440
  unpositioned)
441
+ # The fold also writes the singleton hash of each class this file `extend`s, declared there or not.
442
+ file_extends.each_key { |owner| singleton_def_nodes[owner]&.freeze }
443
+ freeze_def_tables(def_pair, singleton_pair)
444
+ # #2300 — the override gate's definers index then inverts the seed once per run, not once per file.
445
+ DefinersIndex.record_layers(def_nodes, default_scope.discovered_def_nodes)
446
+ DefinersIndex.record_layers(singleton_def_nodes, default_scope.discovered_singleton_def_nodes)
405
447
  [extends, methods_table, unpositioned]
406
448
  end
407
449
 
@@ -432,6 +474,20 @@ module Rigor
432
474
  walked.fetch(:header_nestings)).freeze]
433
475
  end
434
476
 
477
+ # Issue #2330 — the per-file def table is read by identity: the callee-chain census memo is keyed by the
478
+ # {Scope::DiscoveryIndex} that carries it, which is only sound while the table is never written in place.
479
+ # Frozen after the extends fold, the last in-place writer; the seed's per-class hashes are frozen already.
480
+ #
481
+ # Only the per-class hashes of the classes this file declares are frozen here (the merge made or took
482
+ # them); a seed-only class's hash was frozen by `finalize_def_index`, so walking the whole table per file is
483
+ # needless.
484
+ def freeze_def_tables(*pairs)
485
+ pairs.each do |table, file_table|
486
+ file_table.each_key { |owner| table[owner].freeze }
487
+ table.freeze
488
+ end
489
+ end
490
+
435
491
  # The instance-side def-node table and its issue #681 nesting twin, each merged over the cross-file seed.
436
492
  # Returned as a pair so the two stay written together: a node the merge keeps must be the same object the
437
493
  # nesting table keys, and they are only that if both halves take the same file's walk.
@@ -498,25 +554,35 @@ module Rigor
498
554
  # (singleton vs instance). The rvalue is typed with NO local bindings — the pre-pass lacks statement-level
499
555
  # threading — so `@x = 1` records `Constant[1]` but `@x = some_local + 1` records `Dynamic[Top]` (since
500
556
  # `some_local` is unbound at pre-pass time). Multiple writes to the same ivar union via `Type::Combinator.union`.
501
- def build_class_ivar_index(root, default_scope)
557
+ def build_class_ivar_index(root, default_scope, declared_ivar_types: true)
502
558
  accumulator = {}
503
559
  mutated_ivars = {}
504
560
  read_before_write = {}
505
561
  init_writes = {}
506
- # WD3 — per-class summary of `{class_name => {method_name => Set<ivar names definitely assigned non-nil on every
507
- # completing path>}}`, consulted by `dead_transient_nil_writes` so a ctor that reassigns `@x` indirectly through
508
- # an unconditional same-class method call (`mask!`) credits the overwrite. Built once per program here, memoised
509
- # by class.
562
+ # WD3 — per class, then per method, the ivars the method definitely assigns non-nil on every completing path,
563
+ # consulted by `dead_transient_nil_writes` so a ctor that reassigns `@x` indirectly through an unconditional
564
+ # same-class method call (`mask!`) credits the overwrite. Built once per program here, memoised by class.
510
565
  method_assign_effects = build_method_assign_effects(root)
566
+ # #2291 — an `@x = LIT` write reads the file's constant table, built on the first such write.
567
+ accumulator[CONSTANT_IVAR_VALUES] = ConstantIvarValue.new(root, default_scope)
511
568
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
512
569
  read_before_write, init_writes, method_assign_effects)
570
+ accumulator.delete(CONSTANT_IVAR_VALUES)
513
571
  reflective_opaque = accumulator.delete(REFLECTIVE_OPAQUE_CLASSES)
514
572
  exempt_class_body_writes!(init_writes, accumulator.delete(DEFINE_METHOD_WRITES) || {})
515
573
  merge_held_ivar_writes!(accumulator)
516
574
  record_aliased_ivar_mutations!(root, mutated_ivars)
517
575
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
576
+ # #2170 — an ancestor's writes as this file's census types them, before its read-before-write `nil`.
577
+ writes = accumulator.transform_values(&:dup)
518
578
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
519
579
  make_reflective_classes_gradual!(accumulator, reflective_opaque)
580
+ # #2079 / #2170 — another file's writes, and the chain's modules' and superclasses', join existing entries.
581
+ IvarContributions.join!(accumulator, writes, default_scope)
582
+ # An in-place mutation the file observes widens a joined value too (a same-file ancestor's String literal).
583
+ widen_mutated_ivar_entries!(accumulator, mutated_ivars)
584
+ # #1406 — an RBS `@x: T` or `attr_* x: T` declaration replaces the census's join of the writes.
585
+ DeclaredIvarTypes.apply!(accumulator, default_scope) if declared_ivar_types
520
586
  accumulator.transform_values(&:freeze).freeze
521
587
  end
522
588
 
@@ -600,8 +666,8 @@ module Rigor
600
666
  end
601
667
  end
602
668
 
603
- # `{class_name => {method_name => Set<ivar name>}}` for methods that hand an ivar back, dropping the empty sets
604
- # so the caller's `next if per_class.nil?` skips a whole class in one lookup.
669
+ # Per class, then per method, the ivars each method hands back, dropping the empty sets so the caller's
670
+ # `next if per_class.nil?` skips a whole class in one lookup.
605
671
  def returned_ivars_by_method(defs)
606
672
  defs.each_with_object({}) do |(class_name, methods), acc|
607
673
  methods.each do |method_name, def_node|
@@ -690,9 +756,8 @@ module Rigor
690
756
  # The widening uses `MutationWidening.widen_for_mutator` — the same primitive
691
757
  # `Inference::StatementEvaluator#eval_call` applies for per-method-body widening on a local / ivar receiver. The
692
758
  # class-level pass extends that primitive's reach so a `Tuple`-seeded ivar in `initialize` is observed as
693
- # `Nominal[Array]` at the entry of every OTHER method body in the class — closing the cross-method gap noted in
694
- # ROADMAP § Future cycles / Type-language / engine ("Tuple / HashShape widening for ivar-seeded literals after
695
- # mutation"; Redmine 6.1.2 `Redmine::Views::Builders::Structure` is the canonical worked site).
759
+ # `Nominal[Array]` at the entry of every OTHER method body in the class — closing the cross-method gap
760
+ # (Redmine 6.1.2 `Redmine::Views::Builders::Structure` is the canonical worked site).
696
761
  #
697
762
  # Always-safe: the widening can only LOSE precision; the underlying nominal (`Array` / `Hash`) and the element
698
763
  # union are preserved.
@@ -737,18 +802,27 @@ module Rigor
737
802
  end
738
803
 
739
804
  widen_member_for_lookup_mutators(member, observed_methods)
740
- when Type::Constant
741
- # `@s = +"ab"` in one method and `@s << "c"` in another: without this arm the seed stayed pinned at every
742
- # other method's entry, and `@s == "ab"` folded always-truthy on a receiver that holds `"abc"`.
743
- return member unless StringMutation.constant?(member) &&
744
- observed_methods.any? { |m| StringMutation::MUTATORS.include?(m) }
745
-
746
- Type::Combinator.nominal_of("String")
805
+ when Type::Constant then widen_constant_for_observed_mutators(member, observed_methods)
747
806
  else
748
807
  member
749
808
  end
750
809
  end
751
810
 
811
+ # `@s = +"ab"` in one method and `@s << "c"` in another: without this arm the seed stayed pinned at every other
812
+ # method's entry, and `@s == "ab"` folded always-truthy on a receiver that holds `"abc"`. A constant-folded Set
813
+ # (`@s = Set["a"]`, then `@s.clear; @s << 1` elsewhere — issue #2294) takes the untyped floor the Tuple arm takes.
814
+ def widen_constant_for_observed_mutators(member, observed_methods)
815
+ if SetMutation.constant?(member)
816
+ return member unless observed_methods.any? { |m| SetMutation::MUTATORS.include?(m) }
817
+
818
+ return Type::Combinator.nominal_of("Set", type_args: [Type::Combinator.untyped])
819
+ end
820
+ return member unless StringMutation.constant?(member) &&
821
+ observed_methods.any? { |m| StringMutation::MUTATORS.include?(m) }
822
+
823
+ Type::Combinator.nominal_of("String")
824
+ end
825
+
752
826
  # A `HashShape` ivar seed some method gave a default, a default proc or identity keys, and no method stored
753
827
  # into, removed from or rewrote: its pairs are the seed's own, so it takes the per-method {HashLookupMutation}
754
828
  # widening rather than the untyped floor above — a present key keeps its value across methods, a missing one
@@ -1024,8 +1098,11 @@ module Rigor
1024
1098
  # nodes to skip; soundness is post-domination at the top statement level, so dropping the nil never hides a real
1025
1099
  # runtime-nil read.
1026
1100
  dead_writes = dead_transient_nil_writes(def_node.body, class_name, method_assign_effects)
1101
+ # #1154 — an `@x = x` from a declared parameter records the parameter's declared type.
1102
+ declared = DeclaredParamWrites::EMPTY
1103
+ declared = DeclaredParamWrites.collect(def_node, class_name, body_scope) unless singleton
1027
1104
  gather_ivar_writes(def_node.body, body_scope, class_name, accumulator,
1028
- EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes)
1105
+ EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes, declared)
1029
1106
 
1030
1107
  # B2.3 — collect per-method evidence for the read-before- write nil contribution. The accumulator-level decision
1031
1108
  # ("is this ivar truly read-before-write across the class lifetime?") is finalised at
@@ -1285,9 +1362,9 @@ module Rigor
1285
1362
  # not `InstanceVariableWriteNode`s, so the generic descent below never records them as writes. Left unhandled,
1286
1363
  # an ivar written only via massign in `initialize` stays absent from `init_writes`, and
1287
1364
  # `contribute_read_before_write_nil!` then unions a spurious `nil` into its class-ivar seed — masking a
1288
- # genuinely-typed `@m` (e.g. `Tuple[]`) as `T | nil` at every sibling read. The RHS runs before any target is
1289
- # committed, so descend into `value` FIRST (an ivar read there is read-before-write), then mark every ivar
1290
- # target as written.
1365
+ # genuinely-typed `@m` (e.g. `Tuple[]`) as `T | nil` at every sibling read. The RHS runs before any
1366
+ # target is committed, so descend into `value` FIRST (an ivar read there is read-before-write), then mark every
1367
+ # ivar target as written.
1291
1368
  if node.is_a?(Prism::MultiWriteNode)
1292
1369
  detect_read_before_write(node.value, seen_writes, read_first) if node.value
1293
1370
  detect_multi_write_target_writes(node, seen_writes, read_first)
@@ -1341,14 +1418,14 @@ module Rigor
1341
1418
  private_constant :TAIL_VALUE_DEPTH_CAP
1342
1419
  private_constant :EMPTY_GUARDED_IVARS
1343
1420
 
1344
- def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
1345
- mutated_ivars = nil, dead_writes = nil)
1421
+ def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS, # rubocop:disable Metrics/ParameterLists
1422
+ mutated_ivars = nil, dead_writes = nil, declared = DeclaredParamWrites::EMPTY)
1346
1423
  return unless node.is_a?(Prism::Node)
1347
1424
 
1348
1425
  if node.is_a?(Prism::InstanceVariableWriteNode)
1349
1426
  unless dead_writes&.include?(node.object_id)
1350
1427
  record_ivar_write(node, scope, class_name, accumulator,
1351
- guarded: guarded_ivars.include?(node.name))
1428
+ guarded: guarded_ivars.include?(node.name), declared: declared[node])
1352
1429
  end
1353
1430
  else
1354
1431
  record_compound_ivar_write(node, scope, class_name, accumulator)
@@ -1371,12 +1448,12 @@ module Rigor
1371
1448
 
1372
1449
  if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
1373
1450
  walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
1374
- mutated_ivars, dead_writes)
1451
+ mutated_ivars, dead_writes, declared)
1375
1452
  return
1376
1453
  end
1377
1454
 
1378
1455
  node.rigor_each_child do |c|
1379
- gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes)
1456
+ gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes, declared)
1380
1457
  end
1381
1458
  end
1382
1459
 
@@ -1414,13 +1491,21 @@ module Rigor
1414
1491
  method_name, receiver = mutation_target(node)
1415
1492
  return if method_name.nil?
1416
1493
  return unless receiver.is_a?(Prism::InstanceVariableReadNode)
1417
- return unless MutationWidening::SHAPE_MUTATORS.include?(method_name)
1494
+ return unless ivar_census_mutator?(method_name)
1418
1495
 
1419
1496
  per_class = (mutated_ivars[class_name] ||= {})
1420
1497
  per_ivar = (per_class[receiver.name] ||= Set.new)
1421
1498
  per_ivar << method_name
1422
1499
  end
1423
1500
 
1501
+ # The Set-only names (`add`, `merge`, … — issue #2294) are recorded here rather than in `SHAPE_MUTATORS`, which
1502
+ # every scan reads by name alone: there `merge` would make `Hash#merge`, which returns a new Hash, a mutation of
1503
+ # its receiver. The census is safe to over-collect, since {#widen_member_for_observed_mutators} reads each name
1504
+ # against the table of the carrier it widens.
1505
+ def ivar_census_mutator?(method_name)
1506
+ MutationWidening::SHAPE_MUTATORS.include?(method_name) || SetMutation::MUTATORS.include?(method_name)
1507
+ end
1508
+
1424
1509
  # `[mutator name, receiver node]` for a node that mutates its receiver, `nil` otherwise.
1425
1510
  #
1426
1511
  # `@h[k] ||= v` and its `&&=` / `+=` siblings store through `[]=` but are not `[]=` CallNodes; missing them left
@@ -1452,22 +1537,22 @@ module Rigor
1452
1537
  # init; end` would otherwise be marked too — but that pattern (write @x in the else of `if @x`) is a separate
1453
1538
  # idiom whose surrounding reads of `@x` would then surface a nil-receiver FP. The ELSE branch is left ungarded so
1454
1539
  # those reads continue to type as they did before this fix.
1455
- def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
1456
- mutated_ivars = nil, dead_writes = nil)
1540
+ def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, # rubocop:disable Metrics/ParameterLists
1541
+ mutated_ivars = nil, dead_writes = nil, declared = DeclaredParamWrites::EMPTY)
1457
1542
  then_guards = then_body_guarded_ivars(node)
1458
1543
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
1459
1544
 
1460
1545
  gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
1461
- mutated_ivars, dead_writes)
1546
+ mutated_ivars, dead_writes, declared)
1462
1547
  if node.statements
1463
1548
  gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
1464
- mutated_ivars, dead_writes)
1549
+ mutated_ivars, dead_writes, declared)
1465
1550
  end
1466
1551
  branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
1467
1552
  return unless branch
1468
1553
 
1469
1554
  gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
1470
- mutated_ivars, dead_writes)
1555
+ mutated_ivars, dead_writes, declared)
1471
1556
  end
1472
1557
 
1473
1558
  # Returns the set of ivar names that, in the THEN body of this conditional, are statically known to be in a nil /
@@ -1555,10 +1640,9 @@ module Rigor
1555
1640
  SAME_CLASS_CALL_DEPTH_CAP = 3
1556
1641
  private_constant :SAME_CLASS_CALL_DEPTH_CAP
1557
1642
 
1558
- # WD3 — builds the per-class definite-assignment summary `{class_name => {method_name => Set<ivar names assigned
1559
- # non-nil on every completing path>}}`. Used so a ctor's `dead_transient_nil_writes` can credit an indirect
1560
- # overwrite through an unconditionally-called same-class method (ipaddr's `initialize` reassigns `@mask_addr` via
1561
- # `mask!`).
1643
+ # WD3 — builds the per-class definite-assignment summary: per class, then per method, the ivars assigned non-nil
1644
+ # on every completing path. Used so a ctor's `dead_transient_nil_writes` can credit an indirect overwrite through
1645
+ # an unconditionally-called same-class method (ipaddr's `initialize` reassigns `@mask_addr` via `mask!`).
1562
1646
  #
1563
1647
  # Each method's set is computed by the same suffix definite-assignment analysis used for the ctor seed, run from
1564
1648
  # the method body's first statement for every ivar the method writes anywhere. Same-class calls inside a method
@@ -1944,11 +2028,12 @@ module Rigor
1944
2028
  assigns.include?(target)
1945
2029
  end
1946
2030
 
1947
- def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
1948
- rvalue_type = scope.type_of(node.value)
2031
+ def record_ivar_write(node, scope, class_name, accumulator, guarded: false, declared: nil)
2032
+ rvalue_type = declared || accumulator[CONSTANT_IVAR_VALUES]&.type_of(node.value, scope) ||
2033
+ scope.type_of(node.value)
1949
2034
 
1950
2035
  # `@x = nil unless @x` / `@y = false unless @y` — follow-up to the polarity-aware defensive-init guard fix
1951
- # (ROADMAP § Future cycles — "Defensive ivar-init with nil / false rvalue"). When the rvalue is itself a falsey
2036
+ # (defensive ivar-init with a nil / false rvalue). When the rvalue is itself a falsey
1952
2037
  # Constant, `union(rvalue, Constant[nil])` collapses (for `nil`) or doesn't widen the type's truthiness profile
1953
2038
  # (for `false`) — the predicate `unless @x` then folds to a single `Constant[nil]` / `Constant[false]` and the
1954
2039
  # `flow.always-truthy-condition` / `-always-falsey-` rule false-fires on the no-op-but-documented-default idiom.
@@ -1958,6 +2043,8 @@ module Rigor
1958
2043
  # `lib/tdiary/configuration.rb:157` for the worked site.
1959
2044
  return if guarded && falsey_constant?(rvalue_type)
1960
2045
 
2046
+ rvalue_type = EmptySeed.erase(node.value, rvalue_type)
2047
+
1961
2048
  rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
1962
2049
  accumulate_ivar_type(accumulator, class_name, node.name, rvalue_type)
1963
2050
  end
@@ -1987,7 +2074,7 @@ module Rigor
1987
2074
  # `nil`, the guard bound its own ivar, and `ExpressionTyper#compound_write_value` read
1988
2075
  # `truthy(nil) | bot` — an instance method that sig-gen declared `-> bot`.
1989
2076
  def record_ivar_or_write(node, scope, class_name, accumulator)
1990
- rvalue_type = scope.type_of(node.value)
2077
+ rvalue_type = EmptySeed.erase(node.value, scope.type_of(node.value))
1991
2078
  return if Narrowing.narrow_truthy(rvalue_type).is_a?(Type::Bot)
1992
2079
 
1993
2080
  accumulate_ivar_type(accumulator, class_name, node.name,
@@ -2151,7 +2238,9 @@ module Rigor
2151
2238
  CLASS_BODY_WRITES = :class_body_writes
2152
2239
  # The classes a computed-name `instance_variable_set` may write any ivar of ({#record_reflective_ivar_write}).
2153
2240
  REFLECTIVE_OPAQUE_CLASSES = :reflective_opaque_classes
2154
- private_constant :DEFINE_METHOD_WRITES, :CLASS_BODY_WRITES, :REFLECTIVE_OPAQUE_CLASSES
2241
+ # The census's {ConstantIvarValue}, which {#record_ivar_write} reads.
2242
+ CONSTANT_IVAR_VALUES = :constant_ivar_values
2243
+ private_constant :DEFINE_METHOD_WRITES, :CLASS_BODY_WRITES, :REFLECTIVE_OPAQUE_CLASSES, :CONSTANT_IVAR_VALUES
2155
2244
 
2156
2245
  # Unions `type` into the class-ivar accumulator for `(class_name, ivar_name)`. Shared by the single-write and
2157
2246
  # multi-write (parallel-assignment) collectors.
@@ -2184,7 +2273,11 @@ module Rigor
2184
2273
 
2185
2274
  rhs_type = scope.type_of(node.value)
2186
2275
  ivars = MultiTargetBinder.bind_marked(node, rhs_type, scope: scope, soften_slots: false).ivars
2187
- ivars.each { |name, type| accumulate_ivar_type(accumulator, class_name, name, type) }
2276
+ # #2499 — `@a, @b = {}, []` erases each empty seed as a single write does.
2277
+ values = EmptySeed.multi_write_values(node)
2278
+ ivars.each do |name, type|
2279
+ accumulate_ivar_type(accumulator, class_name, name, EmptySeed.erase(values[name], type))
2280
+ end
2188
2281
  end
2189
2282
 
2190
2283
  def falsey_constant?(type)
@@ -2351,7 +2444,7 @@ module Rigor
2351
2444
  end
2352
2445
 
2353
2446
  def record_cvar_write(node, scope, class_name, accumulator)
2354
- rvalue_type = scope.type_of(node.value)
2447
+ rvalue_type = EmptySeed.erase(node.value, scope.type_of(node.value))
2355
2448
  accumulator[class_name] ||= {}
2356
2449
  existing = accumulator[class_name][node.name]
2357
2450
  accumulator[class_name][node.name] =
@@ -2473,7 +2566,7 @@ module Rigor
2473
2566
  end
2474
2567
 
2475
2568
  def record_global_write(node, scope, accumulator)
2476
- rvalue_type = scope.type_of(node.value)
2569
+ rvalue_type = EmptySeed.erase(node.value, scope.type_of(node.value))
2477
2570
  existing = accumulator[node.name]
2478
2571
  accumulator[node.name] =
2479
2572
  existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
@@ -3238,7 +3331,7 @@ module Rigor
3238
3331
  unless owner.empty?
3239
3332
  body_scope = census_body_scope(default_scope, enclosing_prefix, Type::Combinator.singleton_of(owner))
3240
3333
  end
3241
- rvalue_type = meta_new_constant_type(node, full) || body_scope.type_of(node.value)
3334
+ rvalue_type = meta_new_constant_type(node, full) || EmptySeed.erase(node.value, body_scope.type_of(node.value))
3242
3335
  existing = accumulator[full]
3243
3336
  accumulator[full] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
3244
3337
  end
@@ -3433,7 +3526,17 @@ module Rigor
3433
3526
  # walk worked out for it ({#refine_context}).
3434
3527
  MethodTables = Struct.new(:existence, :envelopes, :refinements, :certainty, :certain, :possible,
3435
3528
  :contested_envelopes, :contested_def_nodes, :root, :in_effect, :census,
3436
- :refine_text, :literal_seen, :source_bytes, :loose_constants, :block_spans) do
3529
+ :refine_text, :literal_seen, :source_bytes, :loose_constants, :block_spans,
3530
+ :definer_history) do
3531
+ # Issue #2006 — records, in walk order, that a definer other than an alias wrote the instance method `name`
3532
+ # of `class_name` at `node`: `def_node` for a `def`, nil for a `define_method(:x)` or an `attr_*` reader or
3533
+ # writer, and whether the write is {#certain}. {ScopeIndexer#apply_alias_def_nodes} reads the history to copy
3534
+ # what an alias's old name held at the alias, and to tell an alias a later definer replaced.
3535
+ def wrote_definer(class_name, name, node, def_node)
3536
+ entry = [node.location.start_offset, def_node, certain ? true : false].freeze
3537
+ ((self.definer_history ||= {})[[class_name, name]] ||= []) << entry
3538
+ end
3539
+
3437
3540
  # {ScopeIndexer.module_refine_target} against this tree's {InEffectRefinements}.
3438
3541
  def refine_target_of(node)
3439
3542
  return nil if ScopeIndexer.refine_target(node).nil?
@@ -3526,7 +3629,8 @@ module Rigor
3526
3629
  REFINEMENT_WILDCARD = Scope::DiscoveryIndex::REFINEMENT_WILDCARD
3527
3630
  REFINE_WORD = /\brefine\b/
3528
3631
  HEREDOC_OPENER = /<<[~-]?["'`A-Za-z_]/
3529
- private_constant :EMPTY_REFINEMENTS, :REFINEMENT_WILDCARD, :REFINE_WORD, :HEREDOC_OPENER
3632
+ COMPARISON_OPERATORS = Set[:==, :!=, :===].freeze
3633
+ private_constant :EMPTY_REFINEMENTS, :REFINEMENT_WILDCARD, :REFINE_WORD, :HEREDOC_OPENER, :COMPARISON_OPERATORS
3530
3634
 
3531
3635
  NO_OFFSETS = [].freeze
3532
3636
  private_constant :NO_OFFSETS
@@ -3619,7 +3723,8 @@ module Rigor
3619
3723
  # `self`, charged to its module or the wildcard), `:literal` (A3's `:refine` / eval-string literal), `:nested` (a
3620
3724
  # `refine` in a refine body, or in a singleton method of one), `:dsl` (where `self` is a class, #1689) and
3621
3725
  # `:raises` (no block, a Proc as the block, another receiver, or a computed target where `self` is `main`), and
3622
- # `:data` (a `:refine` literal where `self` is a plain class or one of its instances).
3726
+ # `:data` (a `:refine` literal where `self` is a plain class or one of its instances), and `:inert` (issue #2203:
3727
+ # a `:refine` Symbol that is a hash key or a comparison operand, which no call reads as a method name).
3623
3728
  def refine_census(root)
3624
3729
  census = []
3625
3730
  refinements = build_methods_and_def_nodes(root, census: census)[3]
@@ -3798,6 +3903,8 @@ module Rigor
3798
3903
  end
3799
3904
 
3800
3905
  census_call(node, qualified_prefix, tables, refine_body)
3906
+ when Prism::AssocNode
3907
+ census_assoc_key(node, tables)
3801
3908
  when Prism::SymbolNode
3802
3909
  census_symbol(node, tables)
3803
3910
  end
@@ -3853,6 +3960,8 @@ module Rigor
3853
3960
  arguments = node.arguments&.arguments
3854
3961
  return if arguments.nil?
3855
3962
 
3963
+ return census_compared_literal(arguments.first, tables) if COMPARISON_OPERATORS.include?(node.name)
3964
+
3856
3965
  strings = RefineCensus.string_literal_call?(node)
3857
3966
  arguments.each do |argument|
3858
3967
  next unless RefineCensus.refine_symbol?(argument) || (strings && RefineCensus.refine_string?(argument))
@@ -3887,6 +3996,26 @@ module Rigor
3887
3996
 
3888
3997
  def constant_shaped?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
3889
3998
 
3999
+ # Issue #2203 — a `:refine` Symbol a comparison reads (`node.name == :refine`, `!=`, `===`) is a value compared,
4000
+ # never a method name a call runs: it is inert (`:inert`), and records no row.
4001
+ def census_compared_literal(argument, tables)
4002
+ return unless RefineCensus.refine_symbol?(argument) && !tables.literal_seen?(argument)
4003
+
4004
+ tables.see_literal(argument)
4005
+ tables.account(argument, :inert)
4006
+ end
4007
+
4008
+ # Issue #2203 — a `:refine` Symbol that is a hash key (`h(x, refine: true)`, `{ refine: 1 }`, `{ :refine => 1 }`)
4009
+ # names an option, not `Module#refine`: it is inert (`:inert`), and records no row. A `:refine` value stays a
4010
+ # literal.
4011
+ def census_assoc_key(node, tables)
4012
+ key = node.key
4013
+ return unless RefineCensus.refine_symbol?(key) && !tables.literal_seen?(key)
4014
+
4015
+ tables.see_literal(key)
4016
+ tables.account(key, :inert)
4017
+ end
4018
+
3890
4019
  # A3 — a `:refine` Symbol anywhere else (`alias my_refine refine`, `%i[refine]`, a hash) names `Module#refine`
3891
4020
  # for code the walk cannot follow, run on the module the in-effect walk says `self` is there; where `self` is a
3892
4021
  # plain class or one of its instances it is data.
@@ -4158,6 +4287,8 @@ module Rigor
4158
4287
  end
4159
4288
  end
4160
4289
  return
4290
+ when Prism::AssocNode
4291
+ census_assoc_key(node, methods_acc) if methods_acc.mentions_refine?
4161
4292
  when Prism::SymbolNode
4162
4293
  census_symbol(node, methods_acc) if methods_acc.mentions_refine?
4163
4294
  when Prism::CallNode
@@ -4171,7 +4302,9 @@ module Rigor
4171
4302
  anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
4172
4303
  source_path)
4173
4304
  unless in_singleton_class
4174
- supersede_def_nodes(call_superseded_names(node), owner_prefix, def_nodes_acc, methods_acc.at(node))
4305
+ superseded = call_superseded_names(node)
4306
+ supersede_def_nodes(superseded, owner_prefix, def_nodes_acc, methods_acc.at(node))
4307
+ note_call_definers(node, superseded, owner_prefix, methods_acc.at(node))
4175
4308
  end
4176
4309
  tables = methods_acc.at(node)
4177
4310
  record_singleton_prepend(node, qualified_prefix, tables,
@@ -4811,6 +4944,7 @@ module Rigor
4811
4944
  accumulator[class_name] ||= {}
4812
4945
  accumulator[class_name][def_node.name] = def_node
4813
4946
  follow_slot_writer(tables, :contested_def_nodes, [class_name, def_node.name], tables.nil? || tables.certain)
4947
+ tables&.wrote_definer(class_name, def_node.name, def_node, def_node)
4814
4948
  record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables)
4815
4949
  end
4816
4950
 
@@ -4870,6 +5004,16 @@ module Rigor
4870
5004
  end
4871
5005
  end
4872
5006
 
5007
+ # Issue #2006 — a `define_method(:x)` or `attr_*` call is a definer the alias pass reads in source order
5008
+ # ({#apply_alias_def_nodes}). An `alias_method` call, whose new name {#call_superseded_names} also lists, is
5009
+ # itself an alias: the pass records its copy, so the history must not hold it as a definer with no `def`.
5010
+ def note_call_definers(call_node, names, qualified_prefix, tables)
5011
+ return if names.empty? || qualified_prefix.empty? || call_node.name == :alias_method
5012
+
5013
+ class_name = qualified_prefix.join("::")
5014
+ names.each { |name| tables.wrote_definer(class_name, name, call_node, nil) }
5015
+ end
5016
+
4873
5017
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
4874
5018
  # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
4875
5019
  # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
@@ -5035,7 +5179,7 @@ module Rigor
5035
5179
  # nested `def` or block inside its body is still a range the containment half needs.
5036
5180
  # `def_owner_prefix` is the eval-body override described on {#walk_methods_and_def_nodes}:
5037
5181
  # an explicit owner for def rows while declarations stay lexical (`[]` = ownerless).
5038
- def walk_deferred_ranges(node, qualified_prefix, in_singleton_class, inside_deferred, # rubocop:disable Metrics/ParameterLists
5182
+ def walk_deferred_ranges(node, qualified_prefix, in_singleton_class, inside_deferred, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
5039
5183
  mf_offsets, ranges, def_owner_prefix = nil, singleton_cref: false,
5040
5184
  defs_singleton: false)
5041
5185
  return unless node.is_a?(Prism::Node)
@@ -5071,11 +5215,34 @@ module Rigor
5071
5215
  singleton_cref: singleton_cref)
5072
5216
  end
5073
5217
 
5218
+ record_definer_name_row(node, ranges) if node.is_a?(Prism::CallNode)
5074
5219
  walk_deferred_children(node, qualified_prefix, in_singleton_class, inside_deferred,
5075
5220
  mf_offsets, ranges, def_owner_prefix, singleton_cref: singleton_cref,
5076
5221
  defs_singleton: defs_singleton)
5077
5222
  end
5078
5223
 
5224
+ DEFINER_NAME_CALLS = Set[:define_method, :define_singleton_method, :alias_method].freeze
5225
+ DEFINER_SEND_CALLS = Set[:send, :__send__, :public_send].freeze
5226
+ private_constant :DEFINER_NAME_CALLS, :DEFINER_SEND_CALLS
5227
+
5228
+ # Issue #2184 — a `define_method` / `define_singleton_method` / `alias_method` call with a literal (new) name,
5229
+ # on any receiver and through `send` too, records an ownerless, kindless row over the name literal: no owner or
5230
+ # side is claimed, so no ordering scan matches it and the literal contains no call. It only tells a by-name
5231
+ # reader that the project gives the name a body no def table holds. A computed name records nothing.
5232
+ def record_definer_name_row(node, ranges)
5233
+ arguments = node.arguments&.arguments || []
5234
+ if DEFINER_SEND_CALLS.include?(node.name)
5235
+ return unless DEFINER_NAME_CALLS.include?(literal_method_name(arguments.first))
5236
+
5237
+ arguments = arguments.drop(1)
5238
+ end
5239
+ return unless DEFINER_NAME_CALLS.include?(node.name) || DEFINER_SEND_CALLS.include?(node.name)
5240
+
5241
+ argument = arguments.first
5242
+ name = literal_method_name(argument)
5243
+ ranges << [argument.location.start_offset, argument.location.end_offset, name, nil, nil] if name
5244
+ end
5245
+
5079
5246
  # The `def` arm of {#walk_deferred_ranges}: records the range row — singleton-side when the
5080
5247
  # def sits in a `class <<` body or an `instance_eval` block (`defs_singleton`) — then walks
5081
5248
  # the body as a deferred range.
@@ -5586,7 +5753,9 @@ module Rigor
5586
5753
  mf_offsets, ranges)
5587
5754
  start = def_node.location.start_offset
5588
5755
  if inside_deferred
5589
- ranges << [start, def_node.location.end_offset, nil, nil, nil]
5756
+ # Issue #2184 — the def installs at invocation under a `self` the walk does not know, so the row names the
5757
+ # method but claims no owner or side.
5758
+ ranges << [start, def_node.location.end_offset, def_node.name, nil, nil]
5590
5759
  return
5591
5760
  end
5592
5761
 
@@ -7564,7 +7733,8 @@ module Rigor
7564
7733
  return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
7565
7734
 
7566
7735
  taint_opaque_eval(node, current_class, accumulator)
7567
- return if singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7736
+ return if singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator,
7737
+ in_singleton: in_singleton)
7568
7738
 
7569
7739
  kind, _arguments, via_send = mixin_call_view(node)
7570
7740
  return if kind.nil?
@@ -7572,7 +7742,8 @@ module Rigor
7572
7742
  effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
7573
7743
  .select { |_owner, side| side == :extend || in_singleton }
7574
7744
  return if effects.empty?
7575
- return if !via_send && singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7745
+ return if !via_send && singleton_mixin_recorded?(node, kind, qualified_prefix, current_class, accumulator,
7746
+ in_singleton)
7576
7747
 
7577
7748
  effects.each { |owner, side| accumulator.taint(owner, side) }
7578
7749
  end
@@ -7587,7 +7758,7 @@ module Rigor
7587
7758
  # but only where it is a direct statement (a top-level statement, or one of a direct declaration body or of a
7588
7759
  # block that runs there once): there the methods walk records those marks under the same owner. In a method
7589
7760
  # body, a hook or a conditional it may run on a receiver or at a time the marks cannot place, so it still taints.
7590
- def singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7761
+ def singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator, in_singleton: false)
7591
7762
  receiver = node.receiver
7592
7763
  return false unless singleton_class_of_receiver?(receiver) && surface_rewriting_call?(node)
7593
7764
  return false if singleton_class_named_call?(node) && accumulator.direct?(node)
@@ -7595,10 +7766,33 @@ module Rigor
7595
7766
  owner = singleton_class_owner(node, receiver.receiver, qualified_prefix, current_class)
7596
7767
  return false if owner.nil?
7597
7768
 
7769
+ # Inside `class << self` a bare `singleton_class` is the singleton's own singleton, one level up from the
7770
+ # owner's extend side, so that call stays a taint.
7771
+ up_level = in_singleton && lexical_self_receiver?(receiver.receiver)
7772
+ return true if !up_level && singleton_class_include_recorded?(node, owner, accumulator)
7773
+
7598
7774
  accumulator.taint(owner, :extend)
7599
7775
  true
7600
7776
  end
7601
7777
 
7778
+ # Issue #2094 — `Float.singleton_class.include(Ext)` (or `prepend`, which the extend table cannot order, so the
7779
+ # edge is unpositioned as every receiver-form edge is) puts `Ext` on the singleton side as `Float.extend(Ext)`
7780
+ # does, the fold #915 gives `class << self; include M`. A `send` spelling or an argument that is not a constant
7781
+ # stays a taint.
7782
+ def singleton_class_include_recorded?(node, owner, accumulator)
7783
+ return false unless %i[include prepend].include?(node.name)
7784
+
7785
+ arguments = node.arguments&.arguments || []
7786
+ return false if arguments.empty? || arguments.any? { |arg| Source::ConstantPath.qualified_name(arg).nil? }
7787
+
7788
+ !record_extend_targets(node, owner, accumulator).empty?
7789
+ end
7790
+
7791
+ # A bare or `self.` receiver: the lexical `self`.
7792
+ def lexical_self_receiver?(receiver)
7793
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
7794
+ end
7795
+
7602
7796
  # A bare, `self.` or constant receiver's `singleton_class` call.
7603
7797
  def singleton_class_of_receiver?(receiver)
7604
7798
  receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil?
@@ -7657,13 +7851,29 @@ module Rigor
7657
7851
 
7658
7852
  # Records the singleton-side edges of a receiverless `extend` (or, inside `class << self`, `include` /
7659
7853
  # `prepend`). False when it recorded nothing.
7660
- def singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7661
- return false unless current_class && node.receiver.nil?
7854
+ def singleton_mixin_recorded?(node, kind, qualified_prefix, current_class, accumulator, in_singleton)
7855
+ return receiver_extend_recorded?(node, kind, qualified_prefix, accumulator) if node.receiver
7856
+ return false unless current_class
7662
7857
  return false unless in_singleton ? MIXIN_CALL_NAMES.include?(kind) : kind == :extend
7663
7858
 
7664
7859
  !record_extend_targets(node, current_class, accumulator).empty?
7665
7860
  end
7666
7861
 
7862
+ # Issue #2094 — the receiver form, `Float.extend(Ext)`: the singleton twin of #1722's `Integer.include(Ext)`,
7863
+ # filed under the class the constant receiver names ({#prepend_call_receiver}) and, like that form,
7864
+ # unpositioned wherever it is written ({MixinAccumulator#note}). A `self` argument names the receiver, not the
7865
+ # enclosing class, so a call with one stays a taint. A constant receiver names the same class inside
7866
+ # `class << self`, so the walk's singleton flag does not matter here.
7867
+ def receiver_extend_recorded?(node, kind, qualified_prefix, accumulator)
7868
+ return false if kind != :extend
7869
+ return false if (node.arguments&.arguments || []).any?(Prism::SelfNode)
7870
+
7871
+ owner = prepend_call_receiver(node, qualified_prefix)
7872
+ return false if owner.nil?
7873
+
7874
+ !record_extend_targets(node, owner, accumulator).empty?
7875
+ end
7876
+
7667
7877
  # The table stores search order, not call order: a later `extend` statement prepends its module
7668
7878
  # (`extend A; extend B` → singleton ancestors `[B, A]`) while the arguments of a single
7669
7879
  # `extend A, B` keep call order (`[A, B]`). Prepending each call's argument list preserves both,
@@ -7895,6 +8105,16 @@ module Rigor
7895
8105
  end
7896
8106
  end
7897
8107
 
8108
+ # Issue #2013 — contests the instance slot of `name`, recorded or not, and marks the contest as a
8109
+ # replacement's ({ScopeIndexer.replaced_visibility_mark}): a `define_method(:name)` or an alias gave the name
8110
+ # a visibility the walk does not read, and another file may record the slot. A later certain write in this
8111
+ # walk clears the contest ({#wrote}) and leaves the mark, which then only keeps a contest another file puts on
8112
+ # the slot: the fold declines more, never reports more, and file order is not load order there either.
8113
+ def contest_replaced(class_name, name)
8114
+ contest_slot(class_name, name)
8115
+ contest_slot(class_name, ScopeIndexer.replaced_visibility_mark(name))
8116
+ end
8117
+
7898
8118
  # Contests the `[class_name, name]` slot, recorded or not: a visibility call changed it in a way the walk
7899
8119
  # cannot read (`private def self.x` reaching an instance `x`).
7900
8120
  def contest_slot(class_name, name)
@@ -8231,6 +8451,12 @@ module Rigor
8231
8451
  # them public (`K.public_class_method(*names)`, `K.singleton_class.class_eval { … }`).
8232
8452
  SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
8233
8453
 
8454
+ # Issue #2013 — the mark a contested instance slot `name` carries in `contested_discovered_method_visibilities`
8455
+ # when a `define_method(:name)` or an alias replaced the method: `[:replaced, name]`. No reader asks for it; it
8456
+ # keeps the slot's contest when another file's write of the slot folds later ({#fold_visibility_contests}),
8457
+ # since file order is not load order.
8458
+ def self.replaced_visibility_mark(name) = [:replaced, name].freeze
8459
+
8234
8460
  # Issue #1984 — a class method visibility call written on a constant (`K.public_class_method(:x)`,
8235
8461
  # `K.send(:public_class_method, :x)`), anywhere: each literal name is recorded public on every class the
8236
8462
  # constant may name, so a reopening's or another file's `private_class_method` disagrees with it and is
@@ -8501,19 +8727,21 @@ module Rigor
8501
8727
  Source::AliasNames.alias_method_call_names(call_node)&.first(1) || []
8502
8728
  end
8503
8729
 
8504
- # Issue #1989 — a `define_method` or an alias that replaces a method whose visibility slot this walk recorded
8505
- # gives the name a visibility the walk does not read (the running default for `define_method`, the old
8506
- # name's for an alias), so the slot is contested rather than left asserting the replaced `def`'s. Issue #1984
8507
- # — on the singleton side the slot is contested whether or not this file recorded it, since another file may
8508
- # have.
8730
+ # Issue #1989 — a `define_method` or an alias that replaces a method gives the name a visibility the walk
8731
+ # does not read (the running default for `define_method`, the old name's for an alias), so the slot is
8732
+ # contested rather than left asserting the replaced `def`'s. Issue #1984 — the slot is contested whether or
8733
+ # not this file recorded it, since another file may have; issue #2013 — on the instance side too, with the
8734
+ # mark that keeps the contest when that file's write folds later ({VisibilityTable#contest_replaced}).
8509
8735
  def contest_replaced_visibility(names, qualified_prefix, accumulator, side: :instance)
8510
8736
  return if names.empty? || qualified_prefix.empty?
8511
8737
 
8512
8738
  class_name = qualified_prefix.join("::")
8513
- recorded = accumulator[class_name]
8514
8739
  names.each do |name|
8515
- slot = visibility_slot(name, side)
8516
- accumulator.contest_slot(class_name, slot) if side == :singleton || recorded&.key?(slot)
8740
+ if side == :singleton
8741
+ accumulator.contest_slot(class_name, visibility_slot(name, side))
8742
+ else
8743
+ accumulator.contest_replaced(class_name, name)
8744
+ end
8517
8745
  end
8518
8746
  end
8519
8747
 
@@ -8754,48 +8982,76 @@ module Rigor
8754
8982
  #
8755
8983
  # ADR-119 WD3 — the alias is the slot's writer: the new name's slot is contested when the alias is possible
8756
8984
  # or the slot it copies is contested, and cleared otherwise.
8757
- def apply_alias_def_nodes(root, accumulator, tables = nil)
8758
- alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables&.certainty))
8759
- alias_map.each do |class_name, aliases|
8760
- class_defs = accumulator[class_name]
8761
- next unless class_defs
8762
-
8763
- aliases.each do |new_name, old_name|
8764
- def_node = class_defs[old_name]
8765
- next unless def_node.is_a?(Prism::DefNode)
8766
-
8767
- (accumulator[class_name] ||= {})[new_name] = def_node
8768
- next if tables.nil?
8769
-
8770
- certain = !alias_map.possible?(class_name, new_name) &&
8771
- !tables.contested_def_nodes&.include?([class_name, old_name])
8772
- follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], certain)
8773
- end
8985
+ #
8986
+ # Issue #2006 — in source order. An alias copies what its old name held AT the alias, read from the walk's
8987
+ # definer history ({MethodTables#wrote_definer}) and the copies earlier aliases made: a `def` of the old name
8988
+ # written after the alias (`alias old_x x; def x = 1`) is not what `old_x` runs, and nothing before the alias
8989
+ # copies nothing. The new name's slot then follows its last writer: a later `def` keeps the slot the walk
8990
+ # gave it, and a later `define_method` / `attr_*` leaves the copy contested, as {#supersede_def_nodes} leaves
8991
+ # a replaced `def`.
8992
+ def apply_alias_def_nodes(root, accumulator, tables)
8993
+ alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables.certainty))
8994
+ history = (tables.definer_history || {}).transform_values(&:dup)
8995
+ alias_map.copy_into(history).each { |key| settle_alias_slot(accumulator, tables, key, history[key]) }
8996
+ end
8997
+
8998
+ # Writes the new name's slot from its last writer `writes.last` ({#apply_alias_def_nodes}): an alias copy or a
8999
+ # later `def` writes its `def` with its certainty (for the `def`, what the walk wrote already). A last writer
9000
+ # with no `def` this file holds — a later `define_method` / `attr_*`, or an alias that copies nothing this file
9001
+ # defines (an inherited or core method, an attr reader, a name it cannot read) — contests the slot, which keeps
9002
+ # what it holds or else the latest copy, so no sibling file's `def` fills it.
9003
+ def settle_alias_slot(accumulator, tables, (class_name, new_name), writes)
9004
+ last = writes.last
9005
+ slot = (accumulator[class_name] ||= {})
9006
+ if last[1]
9007
+ slot[new_name] = last[1]
9008
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], last[2])
9009
+ else
9010
+ copy = writes.rfind { |write| write[1] }
9011
+ slot[new_name] ||= copy[1] if copy
9012
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], false)
8774
9013
  end
8775
9014
  end
8776
9015
 
8777
- # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also remembers, per
8778
- # entry, whether the alias that wrote it last is possible ({Certainty}).
9016
+ # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also lists every alias it
9017
+ # met as an event `[class, new name, old name, start offset, possible?]` ({Certainty}).
8779
9018
  class AliasMap < Hash
9019
+ attr_reader :events
9020
+
8780
9021
  def track(certainty)
8781
9022
  @certainty = certainty || Certainty::EMPTY
8782
- @possible = nil
9023
+ @events = []
8783
9024
  self
8784
9025
  end
8785
9026
 
8786
- # Records that `node` wrote the `[class_name, new_name]` entry.
8787
- def wrote(class_name, new_name, node)
9027
+ # Records that `node` wrote the `[class_name, new_name]` entry, aliasing `old_name`.
9028
+ def wrote(class_name, new_name, old_name, node)
8788
9029
  return if @certainty.nil?
8789
9030
 
8790
- if Certainty.possible?(@certainty, node)
8791
- (@possible ||= Set.new) << [class_name, new_name]
8792
- else
8793
- @possible&.delete([class_name, new_name])
8794
- end
9031
+ @events << [class_name, new_name, old_name, node.location.start_offset, Certainty.possible?(@certainty, node)]
9032
+ end
9033
+
9034
+ # Issue #2006 — adds every alias to `history` (`{[class, name] => [[offset, def node or nil, certain,
9035
+ # (:alias)], ...]}`, ascending), in source order. An alias whose old name held a `def` at the alias copies it,
9036
+ # certain when that write and the alias are. Any other alias — an old name with no `def` of this file before
9037
+ # it (inherited, core, an attr reader, a sibling file's), or one it cannot read — adds a write with no `def`,
9038
+ # which leaves the slot contested ({ScopeIndexer#settle_alias_slot}). An alias whose new name it cannot read
9039
+ # adds one to every name of the class written before it. Returns the `[class, name]` keys it wrote.
9040
+ def copy_into(history)
9041
+ @events.sort_by { |event| event[3] }.flat_map do |class_name, new_name, old_name, at, possible|
9042
+ source = old_name && history[[class_name, old_name]]&.rfind { |write| write.first < at }
9043
+ write = if source&.[](1) then [at, source[1], source[2] && !possible, :alias].freeze
9044
+ else [at, nil, false, :alias].freeze
9045
+ end
9046
+ keys = new_name ? [[class_name, new_name]] : names_before(history, class_name, at)
9047
+ keys.each { |key| (history[key] ||= []).push(write).sort_by!(&:first) }
9048
+ end.uniq
8795
9049
  end
8796
9050
 
8797
- def possible?(class_name, new_name)
8798
- !@possible.nil? && @possible.include?([class_name, new_name])
9051
+ private
9052
+
9053
+ def names_before(history, class_name, at)
9054
+ history.filter_map { |key, writes| key if key.first == class_name && writes.first.first < at }
8799
9055
  end
8800
9056
  end
8801
9057
 
@@ -8857,12 +9113,19 @@ module Rigor
8857
9113
  # `t` alias was the corpus case). Unlike AliasMethodNode a call's children can carry further
8858
9114
  # class bodies (`Class.new do … end`), so the walk continues below it either way.
8859
9115
  names = Source::AliasNames.alias_method_call_names(node)
8860
- if names && !rec_prefix.empty? && !(singleton_cref && leaf_owner.nil?)
9116
+ if (names || unread_alias_method?(node)) && !rec_prefix.empty? && !(singleton_cref && leaf_owner.nil?)
8861
9117
  record_alias_call_entry(accumulator, rec_prefix.join("::"), names, node)
8862
9118
  end
8863
9119
  false
8864
9120
  end
8865
9121
 
9122
+ # Issue #2006 — an `alias_method` call on no receiver or `self` whose names {Source::AliasNames} cannot read
9123
+ # (`alias_method n, :to_s`, `self.alias_method :x, :y`, a splat): it still replaces a method, which
9124
+ # {AliasMap#copy_into} contests.
9125
+ def unread_alias_method?(node)
9126
+ node.name == :alias_method && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
9127
+ end
9128
+
8866
9129
  # The rebinding-block call arm of {#collect_class_alias_map}: an anonymous meta-new call
8867
9130
  # (`Class.new { … }` no write names) files its block's aliases nowhere, and an eval-family
8868
9131
  # call binds them on the receiver — a nameable receiver re-anchors the owner while a
@@ -8953,20 +9216,32 @@ module Rigor
8953
9216
  end
8954
9217
 
8955
9218
  def record_alias_call_entry(accumulator, class_name, names, node)
9219
+ if names.nil?
9220
+ # Issue #2006 — an unread `alias_method` maps nothing, but still replaces a method ({AliasMap#copy_into}).
9221
+ new_name = Source::AliasNames.literal_name(node.arguments&.arguments&.first)
9222
+ accumulator.wrote(class_name, new_name, nil, node) if accumulator.is_a?(AliasMap)
9223
+ return
9224
+ end
9225
+
8956
9226
  (accumulator[class_name] ||= {})[names.first] = names.last
8957
- accumulator.wrote(class_name, names.first, node) if accumulator.is_a?(AliasMap)
9227
+ accumulator.wrote(class_name, names.first, names.last, node) if accumulator.is_a?(AliasMap)
8958
9228
  end
8959
9229
 
8960
9230
  def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
8961
9231
  return if qualified_prefix.empty?
8962
9232
 
8963
9233
  names = Source::AliasNames.keyword_names(alias_node)
8964
- return if names.nil?
8965
-
8966
9234
  class_name = qualified_prefix.join("::")
9235
+ if names.nil?
9236
+ # Issue #2006 — an `alias` whose names are not plain symbols still replaces a method ({AliasMap#copy_into}).
9237
+ new_name = Source::AliasNames.symbol_name(alias_node.new_name)
9238
+ accumulator.wrote(class_name, new_name, nil, alias_node) if accumulator.is_a?(AliasMap)
9239
+ return
9240
+ end
9241
+
8967
9242
  new_name, old_name = names
8968
9243
  (accumulator[class_name] ||= {})[new_name] = old_name
8969
- accumulator.wrote(class_name, new_name, alias_node) if accumulator.is_a?(AliasMap)
9244
+ accumulator.wrote(class_name, new_name, old_name, alias_node) if accumulator.is_a?(AliasMap)
8970
9245
  end
8971
9246
 
8972
9247
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
@@ -9153,14 +9428,15 @@ module Rigor
9153
9428
  # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
9154
9429
  # reads their parameter structure from. Issue #1120 — and each file's own refinement table, which the merged
9155
9430
  # def-index cannot attribute back to a file, so the session can diff it against the file's seed bundle.
9156
- # Issue #1459 — and each file's {#defined_method_names}, diffed the same way.
9157
- # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements:, defined_method_names: }`.
9431
+ # Issue #1459 — and each file's {#defined_method_names}, diffed the same way. Issue #2134 — and each file's
9432
+ # {#visibility_entries}. Issue #2158 — and each file's `global_write_census`.
9433
+ # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements:, defined_method_names:,
9434
+ # visibility_entries:, global_write_census: }`.
9158
9435
  def scan_summary_for_paths(paths, buffer: nil)
9159
9436
  acc = new_def_index_accumulator
9160
9437
  code_fingerprints = {}
9161
9438
  declaration_signatures = {}
9162
- refinements = {}
9163
- defined_method_names = {}
9439
+ per_file = { refinements: {}, defined_method_names: {}, visibility_entries: {}, global_write_census: {} }
9164
9440
  paths.each do |path|
9165
9441
  physical = buffer ? buffer.resolve(path) : path
9166
9442
  source = File.read(physical)
@@ -9169,16 +9445,21 @@ module Rigor
9169
9445
  fold_file_index(acc, file_index)
9170
9446
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
9171
9447
  declaration_signatures[path] = declaration_signature(file_index)
9172
- refinements[path] = file_index[:refinements] if file_index[:refinements]
9173
- defined_method_names[path] = defined_method_names(file_index)
9448
+ file_summary(file_index).each { |key, table| per_file[key][path] = table unless table.nil? }
9174
9449
  rescue DeclarationWalk::ContractError
9175
9450
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
9176
9451
  rescue StandardError
9177
9452
  next
9178
9453
  end
9179
9454
  { def_index: finalize_def_index(acc), code_fingerprints: code_fingerprints,
9180
- declaration_signatures: declaration_signatures, refinements: refinements,
9181
- defined_method_names: defined_method_names }
9455
+ declaration_signatures: declaration_signatures, **per_file }
9456
+ end
9457
+
9458
+ # One file's tables {#scan_summary_for_paths} hands the session to diff against the file's seed bundle (nil for
9459
+ # a table the file does not have).
9460
+ def file_summary(file_index)
9461
+ { refinements: file_index[:refinements], defined_method_names: defined_method_names(file_index),
9462
+ visibility_entries: visibility_entries(file_index), global_write_census: file_index[:global_write_census] }
9182
9463
  end
9183
9464
 
9184
9465
  # Issue #1459 — every method name one file's index defines, on any class or module and either side: the keys of
@@ -9192,9 +9473,39 @@ module Rigor
9192
9473
  %i[methods def_nodes singleton_def_nodes].each do |key|
9193
9474
  file_index[key]&.each_value { |table| names.merge(table.keys) }
9194
9475
  end
9476
+ # Issue #2184 — a named deferred-range row with no side (a `def` in a block, a `define_singleton_method`) is a
9477
+ # definition no table above holds.
9478
+ file_index[:deferred_ranges]&.each_value { |rows| rows.each { |row| names << row[2] if row[2] && row[3].nil? } }
9195
9479
  names.freeze
9196
9480
  end
9197
9481
 
9482
+ # Issue #2134 — one file's contributions to the visibility table and its contested sibling, as a Set of
9483
+ # `[class, slot, visibility]` entries and `[:contested, class, slot]` contests, where a slot is a method name or
9484
+ # `[:singleton, name]`. Takes a live file index or a seed bundle, which hold the same keys.
9485
+ #
9486
+ # Issue #2161 — and a `[:module_function, owner, name]` entry for each of the file's deferred-range rows that
9487
+ # `DefinerResolution.module_function_row?` counts (a named def row whose kind is not `:instance`): a module
9488
+ # function's singleton copy is public whatever its instance slot says, so the row is a visibility fact the
9489
+ # table itself does not hold.
9490
+ def visibility_entries(file_index)
9491
+ entries = Set.new
9492
+ file_index[:method_visibilities]&.each do |class_name, table|
9493
+ table.each { |slot, visibility| entries << [class_name, slot, visibility] }
9494
+ end
9495
+ contested = (file_index[:siblings] || {})[:contested_discovered_method_visibilities]
9496
+ contested&.each { |(class_name, slot)| entries << [:contested, class_name, slot] }
9497
+ add_module_function_entries(file_index[:deferred_ranges], entries)
9498
+ entries.freeze
9499
+ end
9500
+
9501
+ def add_module_function_entries(deferred_ranges, entries)
9502
+ deferred_ranges&.each_value do |rows|
9503
+ rows.each do |(_start, _finish, name, kind, owner)|
9504
+ entries << [:module_function, owner, name] if name && owner && kind != :instance
9505
+ end
9506
+ end
9507
+ end
9508
+
9198
9509
  # ADR-89 WD1 — a per-file digest of every cross-file DECLARATION surface an ancestry / file-level
9199
9510
  # dependent consumes, EXCLUDING method bodies (which only symbol dependents consume, via the ADR-46
9200
9511
  # symbol edges the change-detection already fingerprints per method). A body edit that touches no
@@ -9412,14 +9723,21 @@ module Rigor
9412
9723
  # Error degradation is identical to the two independent loops it replaces: a read / parse failure (the
9413
9724
  # rescue's real target) contributes nothing to either table. The subset-scoped callers
9414
9725
  # ({IncrementalSession}, `coverage --protection`) keep calling the individual methods unchanged.
9415
- def discovered_project_index_for_paths(paths, buffer: nil)
9726
+ #
9727
+ # @param uncertain — issue #2021: the paths whose every method contribution is `possible`
9728
+ # ({Certainty.every_contribution}).
9729
+ def discovered_project_index_for_paths(paths, buffer: nil, uncertain: EMPTY_PATH_SET)
9416
9730
  classes = {}
9417
9731
  acc = new_def_index_accumulator
9418
9732
  paths.each do |path|
9419
9733
  physical = buffer ? buffer.resolve(path) : path
9420
9734
  root = Prism.parse(File.read(physical), filepath: path).value
9421
9735
  collect_class_decls(root, [], classes)
9422
- accumulate_project_index(acc, path, root)
9736
+ if uncertain.include?(path)
9737
+ fold_file_index(acc, without_written_slots(build_file_index(path, root, uncertain: true), acc))
9738
+ else
9739
+ accumulate_project_index(acc, path, root)
9740
+ end
9423
9741
  rescue DeclarationWalk::ContractError
9424
9742
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
9425
9743
  rescue StandardError
@@ -9467,14 +9785,19 @@ module Rigor
9467
9785
  # @param paths — project file paths, in canonical order.
9468
9786
  # @param seed_bundles — the prior run's per-file bundles, keyed by logical path.
9469
9787
  # @return `{ classes:, def_index:, bundles: }`.
9470
- def discovered_project_index_incremental(paths, seed_bundles:, buffer: nil)
9788
+ # @param uncertain — issue #2021: as {#discovered_project_index_for_paths}. Such a file is always re-walked and
9789
+ # keeps no bundle, since a bundle does not record that its contributions were all possible.
9790
+ def discovered_project_index_incremental(paths, seed_bundles:, buffer: nil, uncertain: EMPTY_PATH_SET) # rubocop:disable Metrics/AbcSize
9471
9791
  classes = {}
9472
9792
  acc = new_def_index_accumulator
9473
9793
  bundles = {}
9474
9794
  paths.each do |path|
9475
9795
  physical = buffer ? buffer.resolve(path) : path
9476
9796
  digest = Cache::FileDigest.hexdigest(physical)
9477
- cached = seed_bundles[path]
9797
+ walked = uncertain.include?(path)
9798
+ # A walked file neither reuses nor stores a bundle. Both are guards no spec separates: no bundle is ever
9799
+ # stored for a walked file, so the reuse guard matters only if a path changes role within one session.
9800
+ cached = walked ? nil : seed_bundles[path]
9478
9801
  if cached && cached[:digest] == digest
9479
9802
  bundles[path] = cached
9480
9803
  classes.merge!(cached[:classes])
@@ -9483,13 +9806,13 @@ module Rigor
9483
9806
  source = File.read(physical)
9484
9807
  parsed = Prism.parse(source, filepath: path)
9485
9808
  root = parsed.value
9486
- file_index = build_file_index(path, root)
9809
+ file_index = build_file_index(path, root, uncertain: walked)
9487
9810
  file_classes = {}
9488
9811
  collect_class_decls(root, [], file_classes)
9489
- bundles[path] = build_seed_bundle(file_index, file_classes, digest,
9490
- code_fingerprint(source, parsed.comments))
9812
+ fingerprint = code_fingerprint(source, parsed.comments)
9813
+ bundles[path] = build_seed_bundle(file_index, file_classes, digest, fingerprint) unless walked
9491
9814
  classes.merge!(file_classes)
9492
- fold_file_index(acc, file_index)
9815
+ fold_file_index(acc, walked ? without_written_slots(file_index, acc) : file_index)
9493
9816
  end
9494
9817
  rescue DeclarationWalk::ContractError
9495
9818
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
@@ -9503,12 +9826,43 @@ module Rigor
9503
9826
  # Builds ONE file's isolated def-index contribution (live `Prism::DefNode`s) by folding it into a fresh
9504
9827
  # accumulator — recon Q2(a)'s "constructible in isolation" property. Shared by the cold-baseline bundle
9505
9828
  # build and the incremental changed-file re-walk.
9506
- def build_file_index(path, root)
9829
+ def build_file_index(path, root, uncertain: false)
9507
9830
  file_acc = new_def_index_accumulator
9508
- accumulate_project_index(file_acc, path, root)
9831
+ accumulate_project_index(file_acc, path, root, uncertain: uncertain)
9509
9832
  file_acc
9510
9833
  end
9511
9834
 
9835
+ # Issue #2021 — the single-valued slot tables an `uncertain` file writes, each with its contest sibling.
9836
+ UNCERTAIN_SLOT_TABLES = {
9837
+ def_nodes: :contested_discovered_def_nodes,
9838
+ singleton_def_nodes: :contested_discovered_singleton_def_nodes,
9839
+ method_visibilities: :contested_discovered_method_visibilities,
9840
+ parameter_envelopes: :contested_discovered_parameter_envelopes
9841
+ }.freeze
9842
+
9843
+ # Issue #2021 — an `uncertain` file's isolated index without the slots `acc` already holds. The walked files fold
9844
+ # after every other discovered file, so `acc` holds what the configured (certain) files wrote: a walked reopening
9845
+ # of such a class adds the names it defines (existence) and leaves each written def node, visibility and
9846
+ # envelope, and its contest, as the certain writer left it. Without this, the walked file's possible write was
9847
+ # the slot's last and contested it, and `rigor check lib/user.rb plug/x.rb` withheld what `rigor check
9848
+ # lib/user.rb` reports. A slot no earlier file wrote is taken, contested.
9849
+ def without_written_slots(file_index, acc)
9850
+ siblings = (file_index[:siblings] || {}).dup
9851
+ tables = UNCERTAIN_SLOT_TABLES.to_h do |table, sibling|
9852
+ written = acc[table]
9853
+ kept = {}
9854
+ (file_index[table] || {}).each do |class_name, entries|
9855
+ sitting = written[class_name]
9856
+ entries = entries.reject { |key, _| sitting.key?(key) } if sitting
9857
+ kept[class_name] = entries unless entries.empty?
9858
+ end
9859
+ contests = siblings[sibling]
9860
+ siblings[sibling] = contests.select { |class_name, key| kept[class_name]&.key?(key) }.to_set if contests
9861
+ [table, kept]
9862
+ end
9863
+ file_index.merge(tables).merge(siblings: siblings)
9864
+ end
9865
+
9512
9866
  # ADR-85 WD2 — folds a single file's isolated def-index contribution into the cross-file accumulator,
9513
9867
  # applying EXACTLY the merge semantics {#accumulate_project_index} applies incrementally (def_nodes
9514
9868
  # later-wins, def_sources first-wins, includes / class_sources accumulate, everything else later-wins).
@@ -9645,9 +9999,14 @@ module Rigor
9645
9999
  # Issue #1984 — a singleton slot (`[:singleton, name]`) does not follow its writer: file order is not load
9646
10000
  # order, so two files that give it different visibilities (`private_class_method :x` here,
9647
10001
  # `class << self; public :x; end` in a reopening) leave it contested, and a contest on it stays whichever
9648
- # file folds later. The instance slots fold as before.
10002
+ # file folds later. The instance slots fold as before, except (issue #2013) one whose contest carries a
10003
+ # replacement's mark ({ScopeIndexer.replaced_visibility_mark}): a `define_method` or an alias in one file and a
10004
+ # `private def` in another leave the visibility to load order, so that contest stays too.
9649
10005
  def fold_visibility_contests(sitting, recorded, file_slots, file_contested)
9650
- stale = sitting.select { |class_name, name| !name.is_a?(Array) && file_slots[class_name]&.key?(name) }
10006
+ stale = sitting.select do |class_name, name|
10007
+ !name.is_a?(Array) && file_slots[class_name]&.key?(name) &&
10008
+ !sitting.include?([class_name, ScopeIndexer.replaced_visibility_mark(name)])
10009
+ end
9651
10010
  disagreeing = singleton_visibility_disagreements(recorded, file_slots)
9652
10011
  return sitting if stale.empty? && file_contested.empty? && disagreeing.empty?
9653
10012
 
@@ -10070,12 +10429,12 @@ module Rigor
10070
10429
  # Folds one file's class-keyed indexes into the cross-file accumulator. `method_visibilities` (ADR-35) is
10071
10430
  # collected here so the override-visibility-reduced rule can read an ancestor's visibility declared in a sibling
10072
10431
  # file.
10073
- def accumulate_project_index(acc, path, root)
10432
+ def accumulate_project_index(acc, path, root, uncertain: false)
10074
10433
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
10075
10434
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
10076
10435
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
10077
10436
  file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty =
10078
- walk_def_contributions(acc, path, root)
10437
+ walk_def_contributions(acc, path, root, uncertain: uncertain)
10079
10438
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
10080
10439
  fold_parameter_envelopes(acc, file_envelopes)
10081
10440
  fold_refinements(acc, file_refinements)
@@ -10102,8 +10461,8 @@ module Rigor
10102
10461
  # file's siblings folded into the accumulator BEFORE its members join it (ADR-119 WD3: the `possible` rule
10103
10462
  # reads the accumulator's member as it stood). Returns `[methods, def_nodes, envelopes, refinements,
10104
10463
  # singleton_def_nodes, certainty]`, the classification passed on for the visibility walk.
10105
- def walk_def_contributions(acc, path, root)
10106
- certainty = Certainty.possible_nodes(root)
10464
+ def walk_def_contributions(acc, path, root, uncertain: false)
10465
+ certainty = uncertain ? Certainty.every_contribution(root) : Certainty.possible_nodes(root)
10107
10466
  file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
10108
10467
  build_methods_and_def_nodes(root, path, certainty: certainty)
10109
10468
  file_singleton_def_nodes, file_singleton_contested = build_singleton_def_tables(root, certainty: certainty)
@@ -10149,7 +10508,16 @@ module Rigor
10149
10508
  census.writes.each do |name, descriptor|
10150
10509
  (acc[:constant_writes][name] ||= {})[path] = descriptor
10151
10510
  end
10152
- acc[:global_write_census].merge(census.write_census.census)
10511
+ acc[:global_write_census].merge(attribute_own_writes(census.write_census.census, path))
10512
+ end
10513
+
10514
+ # #1154 — tags each `[:own_write, class, ivar]` entry with the file that records it, so a reader of the merged
10515
+ # census can tell a second file writing the same ivar of the same class (`UnseenIvarWriters`).
10516
+ # Issue #2079 — and each `[:own_write_type, class, ivar, value]` entry, which {IvarContributions} reads.
10517
+ def attribute_own_writes(census, path)
10518
+ return census unless census.any? { |entry| PATH_TAGGED_KINDS.include?(entry.first) }
10519
+
10520
+ census.to_set { |entry| PATH_TAGGED_KINDS.include?(entry.first) ? [*entry, path].freeze : entry }
10153
10521
  end
10154
10522
 
10155
10523
  # Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
@@ -10272,8 +10640,12 @@ module Rigor
10272
10640
  singleton_cref: false)
10273
10641
  return unless node.is_a?(Prism::Node)
10274
10642
 
10643
+ # #1154 — the class whose instance `def`s the ivar-writer census credits; none under `class << self`.
10644
+ ivar_owner = census_ivar_owner(qualified_prefix, self_owner, singleton_cref)
10275
10645
  case node
10276
10646
  when Prism::SingletonClassNode
10647
+ # #1154 — a `class << self` inside an instance `def` opens the instance's singleton class.
10648
+ tables.write_census.visit(node, top_level: qualified_prefix.empty?, owner: ivar_owner)
10277
10649
  return walk_census_singleton_class(node, qualified_prefix, tables, self_owner,
10278
10650
  meta_owner, singleton_cref)
10279
10651
  when Prism::ClassNode, Prism::ModuleNode
@@ -10281,7 +10653,7 @@ module Rigor
10281
10653
  singleton_cref)
10282
10654
  else
10283
10655
  census_constant_write(node, qualified_prefix, tables, self_owner, singleton_cref: singleton_cref)
10284
- tables.write_census.visit(node, top_level: qualified_prefix.empty?)
10656
+ tables.write_census.visit(node, top_level: qualified_prefix.empty?, owner: ivar_owner)
10285
10657
  end
10286
10658
 
10287
10659
  rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner, self_owner)
@@ -10313,6 +10685,16 @@ module Rigor
10313
10685
  end
10314
10686
  end
10315
10687
 
10688
+ # #1154 — the class whose instance `def`s the ivar-writer census credits: the class a `Struct.new` /
10689
+ # `Data.define` / `Class.new` constant or a `K.class_eval` block opens (`self_owner`), else the lexical class;
10690
+ # none under `class << self` or an opaque block. The prefix Array is joined only when an entry is recorded.
10691
+ def census_ivar_owner(qualified_prefix, self_owner, singleton_cref)
10692
+ return self_owner if self_owner.is_a?(String)
10693
+ return nil if self_owner || singleton_cref || qualified_prefix.empty?
10694
+
10695
+ qualified_prefix
10696
+ end
10697
+
10316
10698
  # Under an unnameable cref a bare/`self::` header pushes `#<singleton>::Name` — still
10317
10699
  # unnameable — so the body keeps the OPAQUE self and the cref flag; nameable
10318
10700
  # headers re-anchor at a real cref.
@@ -10568,7 +10950,7 @@ module Rigor
10568
10950
  # A {DYNAMIC_TARGET_PREFIX} key is not a name and never publishes; it is expanded by
10569
10951
  # {#apply_dynamic_target_writes} into a retraction over the names it could have written.
10570
10952
  #
10571
- # @return `[{name => Type}, {name => Set[path]}]`.
10953
+ # @return the value table and the table of source paths per name.
10572
10954
  def finalize_constant_writes(writes)
10573
10955
  values = {}
10574
10956
  sources = {}
@@ -11400,6 +11782,7 @@ module Rigor
11400
11782
  table[node] = parent_scope
11401
11783
  parent_scope
11402
11784
  end
11785
+ evaluate_unentered_flow(node, table, current_scope)
11403
11786
 
11404
11787
  case node
11405
11788
  when Prism::IfNode
@@ -11415,7 +11798,7 @@ module Rigor
11415
11798
  child_scope = entered ? current_scope : closure_scope(node, current_scope)
11416
11799
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
11417
11800
  when Prism::CallNode
11418
- propagate_call(node, table, current_scope)
11801
+ propagate_call(node, table, current_scope, recorded: recorded)
11419
11802
  when Prism::RescueNode, Prism::EnsureNode
11420
11803
  # Issue #1360 — a clause the evaluator did not enter (a `begin` in a value position) reads `$!`, `$@` and
11421
11804
  # `$?` unbound, not the enclosing clause's; one it entered keeps what it recorded.
@@ -11429,6 +11812,84 @@ module Rigor
11429
11812
  end
11430
11813
  end
11431
11814
 
11815
+ # Issues #2196 and #2056 — the constructs whose parts run under a narrowing their own condition or left operand
11816
+ # makes, keyed to the part {StatementEvaluator} records first when it enters one. Issue #2214 — a `&&=` on a
11817
+ # variable runs its value under the variable's truthy narrowing (`[c &&= c + 1]`).
11818
+ UNENTERED_FLOW_ENTRY = {
11819
+ Prism::AndNode => :left, Prism::OrNode => :left,
11820
+ Prism::IfNode => :predicate, Prism::UnlessNode => :predicate,
11821
+ Prism::WhileNode => :predicate, Prism::UntilNode => :predicate,
11822
+ Prism::LocalVariableAndWriteNode => :value, Prism::InstanceVariableAndWriteNode => :value,
11823
+ Prism::ClassVariableAndWriteNode => :value, Prism::GlobalVariableAndWriteNode => :value,
11824
+ Prism::CaseNode => :predicate
11825
+ }.freeze
11826
+ private_constant :UNENTERED_FLOW_ENTRY
11827
+
11828
+ UNENTERED_EVALUATOR_KEY = :rigor_scope_index_unentered_evaluator
11829
+ private_constant :UNENTERED_EVALUATOR_KEY
11830
+
11831
+ # Issues #2196 and #2056 — an `&&` / `||` / `and` / `or`, an `if` / `unless` / ternary or a `while` / `until`
11832
+ # in a value position (an array element, a hash value, a call argument, an interpolation, a multi-value
11833
+ # `return`) is typed by `ExpressionTyper` and never entered, so no part of it was recorded and this walk handed
11834
+ # every part one scope. A walk that narrows by the condition's edges cannot follow a write inside the narrowed
11835
+ # part (`[c && (c = c.size; c.even?)]`, `[x || (set!; @x.upcase)]`) nor one inside a block in an earlier
11836
+ # conjunct (`[x.nil? && [1].each { x = "d" } && x.upcase]`). Such a construct is evaluated here instead, from
11837
+ # the scope this walk reached it with, under the index's own recorder, so each part is recorded with the scope
11838
+ # the statement form records it with. What it does not visit (a dead arm) is walked as before.
11839
+ def evaluate_unentered_flow(node, table, current_scope)
11840
+ entry = UNENTERED_FLOW_ENTRY[node.class]
11841
+ return if entry.nil?
11842
+
11843
+ # An entered construct is already recorded; skipping it is a performance guard, not a behaviour. A `case`
11844
+ # without a subject records its first `when` test first.
11845
+ part = node.public_send(entry) || (node.conditions.first&.conditions&.first if node.is_a?(Prism::CaseNode))
11846
+ return if part.nil? || table.key?(part)
11847
+
11848
+ evaluate = Thread.current[UNENTERED_EVALUATOR_KEY]
11849
+ evaluate&.call(node, current_scope, code_body_nodes(node, current_scope.source_path))
11850
+ end
11851
+
11852
+ # The evaluation of an unentered construct starts with no class frame and no lexical nesting, which only a code
11853
+ # body observes: a `def`, a `class` / `module` / `class << …` body, and the block of a class-creating call
11854
+ # (`Class.new { … }`, `Struct.new`, `Data.define`, `Module.new`) or of a `refine`, whose `def`s and constants
11855
+ # resolve against the class the walk tracks and the evaluation does not. Every node in one is left out of the
11856
+ # recording, so the walk reaches it unrecorded and handles it as it did before #2196 ({#unentered_block_entry},
11857
+ # {#record_class_body_defs}).
11858
+ # Answers nil, allocating nothing, for a construct holding no code body: the common case.
11859
+ def code_body_nodes(node, source_path, acc = nil, inside: false)
11860
+ inside ||= CODE_BODY_NODES.include?(node.class)
11861
+ (acc ||= Set.new.compare_by_identity) << node if inside
11862
+ if !inside && node.is_a?(Prism::CallNode) && class_body_block_call?(node, source_path)
11863
+ acc = code_body_nodes(node.block, source_path, acc, inside: true)
11864
+ node.rigor_each_child do |child|
11865
+ acc = code_body_nodes(child, source_path, acc) unless child.equal?(node.block)
11866
+ end
11867
+ return acc
11868
+ end
11869
+ node.rigor_each_child { |child| acc = code_body_nodes(child, source_path, acc, inside: inside) }
11870
+ acc
11871
+ end
11872
+
11873
+ CODE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
11874
+ private_constant :CODE_BODY_NODES
11875
+
11876
+ def class_body_block_call?(node, source_path)
11877
+ node.block.is_a?(Prism::BlockNode) &&
11878
+ (!AnonymousMetaClass.name_for(node, source_path).nil? || !refine_target(node).nil?)
11879
+ end
11880
+
11881
+ # The evaluation {#evaluate_unentered_flow} runs for the walk of one {#index}, installed for that walk only.
11882
+ # Index walks nest (a rule may re-index a file while another file's walk is suspended), so the outer one is
11883
+ # restored on exit. A `propagate` called directly while an outer index walk is running would record into the
11884
+ # OUTER index's table through it; nothing outside {#index} calls `propagate`.
11885
+ def with_unentered_evaluator(evaluate)
11886
+ outer = Thread.current[UNENTERED_EVALUATOR_KEY]
11887
+ Thread.current[UNENTERED_EVALUATOR_KEY] = evaluate
11888
+ yield
11889
+ ensure
11890
+ Thread.current[UNENTERED_EVALUATOR_KEY] = outer
11891
+ end
11892
+
11432
11893
  SELF_OPENING_BODIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
11433
11894
  private_constant :SELF_OPENING_BODIES
11434
11895
 
@@ -11463,11 +11924,11 @@ module Rigor
11463
11924
  # argument one scope and threads none, so arguments that write keep the call's: an earlier one may rebind the
11464
11925
  # receiver a later one reads (`return y&.concat((y = nil).to_s, y.upcase)`, where the evaluator threads no
11465
11926
  # operand).
11466
- def propagate_call(node, table, current_scope)
11927
+ def propagate_call(node, table, current_scope, recorded: false)
11467
11928
  block = node.block
11468
11929
  operands =
11469
11930
  OperandEffects.any?(node.arguments) ? current_scope : Narrowing.safe_navigation_scope(node, current_scope)
11470
- entry = unentered_block_entry(node, block, table, current_scope)
11931
+ entry = unentered_block_entry(node, block, table, current_scope, call_recorded: recorded)
11471
11932
  if entry.equal?(current_scope) && operands.equal?(current_scope)
11472
11933
  node.rigor_each_child { |child| propagate(child, table, current_scope) }
11473
11934
  return
@@ -11491,7 +11952,7 @@ module Rigor
11491
11952
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
11492
11953
  end
11493
11954
 
11494
- def unentered_block_entry(node, block, table, current_scope)
11955
+ def unentered_block_entry(node, block, table, current_scope, call_recorded: false)
11495
11956
  return current_scope unless block.is_a?(Prism::BlockNode) && !table.key?(block)
11496
11957
 
11497
11958
  narrowing = unentered_block_narrowing(node, current_scope)
@@ -11504,6 +11965,12 @@ module Rigor
11504
11965
  else
11505
11966
  LastLine.block_entry(FreshFrameBlocks.closure_entry(current_scope, block, node), block, node)
11506
11967
  end
11968
+ # Issue #2196 — a class-body block whose call the evaluator recorded but did not enter (one inside a construct
11969
+ # {#evaluate_unentered_flow} evaluated, or a later operand) starts from the scope the call was evaluated in,
11970
+ # which may narrow a captured local the body writes; this walk runs the body's statements from one scope, so
11971
+ # such a local reads `Dynamic[top]` in it: `[c && Class.new { c = c.size; c.even? }]`. A block whose call was
11972
+ # not recorded keeps the enclosing scope as before.
11973
+ entry = widen_condition_writes(block, entry) if call_recorded && class_body_block_call?(node, entry.source_path)
11507
11974
  anonymous = AnonymousMetaClass.name_for(node, current_scope.source_path)
11508
11975
  return unentered_meta_class_body_entry(block, entry, anonymous, table) if anonymous
11509
11976
 
@@ -11690,7 +12157,8 @@ module Rigor
11690
12157
  # A test that calls a method may rebind an instance-variable or global subject before the body reads it
11691
12158
  # (`when String then 1 when reset! then @x.upcase`, where `reset!` writes `@x`), and the walk cannot tell. From
11692
12159
  # the first such test on, the subject reads `Dynamic[top]` on both edges ({#forget_called_subject}). The
11693
- # evaluator keeps the narrowing there (a separate defect); the walk declines it rather than add a report.
12160
+ # evaluator forgets a global subject there too (issue #2211). A `case` / `when` the evaluator did not enter is
12161
+ # evaluated ({#evaluate_unentered_flow}); this walk handles only a clause that evaluation does not visit.
11694
12162
  def propagate_subject_case(node, table, current_scope)
11695
12163
  subject = node.predicate
11696
12164
  propagate(subject, table, current_scope)