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
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../configuration"
6
6
  require_relative "../environment"
7
+ require_relative "../run_scoped_slots"
7
8
  require_relative "../project_environment"
8
9
  require_relative "../scope"
9
10
  require_relative "../reflection"
@@ -14,6 +15,7 @@ require_relative "../source/literals"
14
15
  require_relative "../source/node_children"
15
16
  require_relative "../inference/def_return_typer"
16
17
  require_relative "../inference/scope_indexer"
18
+ require_relative "../inference/project_seed_tables"
17
19
  require_relative "../inference/module_function_state"
18
20
  require_relative "../inference/rbs_type_translator"
19
21
  require_relative "alias_index"
@@ -25,6 +27,7 @@ require_relative "declaration_equivalence"
25
27
  require_relative "inline_declarations"
26
28
  require_relative "layout_index"
27
29
  require_relative "method_candidate"
30
+ require_relative "type_name_qualification"
28
31
  require_relative "writer"
29
32
  require_relative "../rooted_glob"
30
33
 
@@ -78,16 +81,20 @@ module Rigor
78
81
  attr_reader :unrenderable, :unresolvable_superclasses
79
82
 
80
83
  # @param paths — files / directories to scan.
81
- # @param observations — => Array<Array<Rigor::Type>>}]
84
+ # @param observations —
82
85
  # ADR-14 slice 3 — per-target-method arg-tuple observations
83
86
  # produced by {ObservationCollector}. An empty Hash (the default)
84
87
  # means "no observations available; emit `untyped` for every
85
88
  # parameter position" per ADR-5 clause 2.
89
+ # @param target_writer — the {Writer} a `--write` or `--check` run hands the candidates to. When given, a
90
+ # class counts as written for a subclass only if that writer accepts its target file (#2461); nil (print,
91
+ # diff, JSON) counts every class with a candidate the writer is sure to write.
86
92
  # @param overwrite — `--overwrite`: a member declared inline whose `sig/` copy disagrees is proposed as an
87
93
  # `inline-overwrite` of the whole member instead of refused (ADR-112 WD4).
88
94
  def initialize(configuration:, paths:, observations: {}, include_private: false, effect_annotator: nil,
89
- overwrite: false)
95
+ overwrite: false, target_writer: nil)
90
96
  @configuration = configuration
97
+ @target_writer = target_writer
91
98
  @overwrite = overwrite
92
99
  @paths = paths
93
100
  @observations = normalize_observations(observations)
@@ -99,7 +106,7 @@ module Rigor
99
106
  # candidates from one file don't leak into another; initialising empty here gives downstream consumers
100
107
  # (`build_candidate`, `method_def_prefix`) a never-nil invariant without per-call-site defensive guards.
101
108
  @namespace_kinds = {}
102
- @module_function_methods = Set.new
109
+ @module_function_methods = Set.new.compare_by_identity
103
110
  @uncertain_module_function_defs = Set.new.compare_by_identity
104
111
  @class_shells = Set.new
105
112
  @class_superclasses = {}
@@ -113,6 +120,36 @@ module Rigor
113
120
  @unresolvable_superclasses = {}
114
121
  # Issue #722 — whole-run: `{ class name => the lexical nesting its header is written in }`.
115
122
  @superclass_nestings = {}
123
+ # Issue #2437 — whole-run: every class a walked file declares with a `class` keyword or a `Data.define` /
124
+ # `Struct.new` layout, so the report can tell a project superclass this run did not write from one nothing
125
+ # declares.
126
+ @source_classes = Set.new
127
+ end
128
+
129
+ # How the report names an unresolvable superclass token ({#unresolvable_superclasses}). It is a project
130
+ # superclass when it names a class the walked source declares and this run wrote nothing for, whose own chain
131
+ # would close if the project's classes were written:
132
+ # each step a source class, ending at no superclass or at one the RBS environment knows (#2437). What is
133
+ # missing is then the project's own declaration, not a gem's RBS. `class ApplicationRecord <
134
+ # ActiveRecord::Base` with no RBS for the gem is not such a class: writing it would not help.
135
+ #
136
+ # Returns `[:project, token]` for such a class. Otherwise `[:external, root]`, where `root` is the first token
137
+ # on the chain that is not a source class: `User < ApplicationRecord` reports `ActiveRecord::Base`, the class
138
+ # whose RBS is missing, rather than the project class between them.
139
+ def unresolvable_superclass_report(token)
140
+ name = bare_superclass_name(token)
141
+ root = token
142
+ seen = {}
143
+ while @source_classes.include?(name) && !seen[name]
144
+ seen[name] = true
145
+ nesting, superclass = @superclass_nestings[name]
146
+ return [:project, token] if superclass.nil?
147
+
148
+ root = resolve_superclass_token(superclass, nesting, @source_classes, name)
149
+ name = bare_superclass_name(root)
150
+ return [:project, token] if Reflection.rbs_class_known?(name, environment: @environment)
151
+ end
152
+ [:external, root]
116
153
  end
117
154
 
118
155
  # Lifts legacy plain-`Array[Type]` observation entries into {ObservedCall} carriers. Specs from the
@@ -126,13 +163,30 @@ module Rigor
126
163
 
127
164
  def run
128
165
  resolved = resolve_paths(@paths)
166
+ @resolved_paths = resolved
129
167
  @environment = build_environment(resolved)
168
+ # ADR-84 WD2, as `Runner#run_analysis` mints it for `check` (#2300): one return-memo bucket for the whole
169
+ # run. Without it the memo keys on each file's merged `discovered_def_nodes`, a new table per file, so a
170
+ # callee reached from two files was re-inferred in each. A fresh token per run keeps a second run in one
171
+ # process from reading the first's entries, and the bucket is released with the other run-scoped slots
172
+ # when the run ends ({RunScopedSlots}), so a finished run holds none of it.
173
+ @run_generation = Object.new.freeze
174
+ # Both read the files as they are now, so a second `run` of one generator re-reads them.
175
+ @project_tables = nil
176
+ @file_base_scope = nil
177
+ # #2461 — which `sig/` targets the writer will write to, read from the files as they are now.
178
+ @writable_targets = {}
130
179
  candidates = resolved.flat_map { |path| analyse_file(path, @environment) }
131
- annotate_effects(
132
- demote_overridden_base_methods(
133
- demote_unresolvable_superclasses(demote_inline_generic_classes(resolve_superclass_spellings(candidates)))
180
+ demoted = demote_overridden_base_methods(
181
+ demote_unresolvable_superclasses(
182
+ demote_generic_reopenings(demote_inline_generic_classes(resolve_superclass_spellings(candidates)))
134
183
  )
135
184
  )
185
+ # Again after the override demotion: a demoted method can leave its file with nothing to write, and a class
186
+ # shell riding on that file then declares nothing a subclass elsewhere could rest on.
187
+ annotate_effects(stamp_generic_reopenings(demote_unresolvable_superclasses(demoted)))
188
+ ensure
189
+ RunScopedSlots.release
136
190
  end
137
191
 
138
192
  private
@@ -207,10 +261,10 @@ module Rigor
207
261
  decls.each do |decl|
208
262
  next unless decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
209
263
 
210
- name = (prefix + [decl.name.to_s.delete_prefix("::")]).join("::")
211
- return decl if name == class_name
264
+ path = ::Rigor::Environment::RbsLoader.declared_class_path(decl, prefix)
265
+ return decl if path.join("::") == class_name
212
266
 
213
- found = find_declaration(decl.members, name.split("::"), class_name)
267
+ found = find_declaration(decl.members, path, class_name)
214
268
  return found if found
215
269
  end
216
270
  nil
@@ -221,6 +275,88 @@ module Rigor
221
275
  (1..segments.size).any? { |n| generic.key?(segments.first(n).join("::")) }
222
276
  end
223
277
 
278
+ # Issue #2436 — a class the loaded RBS declares generic (`Array`, `Hash`, a gem's or the project's own) is
279
+ # reopened with that declaration's type parameters copied onto the header ({#stamp_generic_reopenings}),
280
+ # which is exact only while every parameter is plain. A bound or a default is a type spelled relative to the
281
+ # declaring file's namespace, and copying it under another nesting can resolve it to a different class, so
282
+ # rbs would reject the header as it rejects a parameterless one (`GenericParameterMismatchError`): the class,
283
+ # and every class whose signature mentions it, fails its definition build. A method of such a class, or of
284
+ # one nested in it, is skipped instead — unless `sig/` already declares the class, whose own header the
285
+ # writer then writes into ({#demote_inline_generic_classes}' rule).
286
+ def demote_generic_reopenings(candidates)
287
+ loader = @environment&.rbs_loader
288
+ return candidates if loader.nil?
289
+
290
+ layout = nil
291
+ candidates.map do |candidate|
292
+ next candidate unless Classification::EMITTABLE.include?(candidate.classification)
293
+ next candidate unless namespace_prefixes(candidate.class_name).any? { |name| bounded_generic?(loader, name) }
294
+
295
+ layout ||= LayoutIndex.new(signature_paths: @configuration.signature_paths)
296
+ next candidate if layout.file_for(candidate.class_name)
297
+
298
+ demoted_candidate(candidate, :generic_reopening)
299
+ end
300
+ end
301
+
302
+ # The other half of {#demote_generic_reopenings}: every emittable candidate carries the header spelling of
303
+ # each generic class the run may open a header for — its own class, the classes it is nested in, and the
304
+ # file's class shells and theirs — so the writer and `--print` spell the same header. Method bodies keep
305
+ # `untyped` for element types; nothing here names `E` in a member.
306
+ #
307
+ # Issue #2580 — a skipped candidate is stamped too, and its class counts towards the map: the writer
308
+ # repairs a parameterless header an earlier run wrote into the file a candidate routes to, and a file whose
309
+ # every proposal is already declared (or untyped) would otherwise keep a header rbs rejects.
310
+ def stamp_generic_reopenings(candidates)
311
+ loader = @environment&.rbs_loader
312
+ return candidates if loader.nil?
313
+
314
+ spelled = header_type_params(candidates.flat_map { |c| [c.class_name, *c.class_shells] }.compact)
315
+ return candidates if spelled.empty?
316
+
317
+ candidates.map { |candidate| candidate.with_class_type_params(spelled) }
318
+ end
319
+
320
+ # #2603 — the header spelling {#header_type_params} gives `class_name` alone, or nil when the loaded RBS does
321
+ # not declare it generic with plain parameters only. The writer asks it for a bare header in a `sig/` file no
322
+ # candidate of the run routes to. Nil before {#run} has built the environment.
323
+ def generic_header_spelling(class_name)
324
+ loader = @environment&.rbs_loader
325
+ return nil if loader.nil?
326
+
327
+ params = loader.class_declaration_type_params(class_name)
328
+ "[#{params.join(', ')}]" if !params.empty? && params.all? { |p| plain_type_param?(p) }
329
+ end
330
+ public :generic_header_spelling
331
+
332
+ # `{ class name => "[unchecked out E]" }` for every name in `names`, or a namespace it is nested in, whose
333
+ # loaded declaration is generic with plain parameters only.
334
+ def header_type_params(names)
335
+ names.uniq.flat_map { |name| namespace_prefixes(name) }.uniq.each_with_object({}) do |name, acc|
336
+ spelling = generic_header_spelling(name)
337
+ acc[name] = spelling if spelling
338
+ end.freeze
339
+ end
340
+
341
+ def namespace_prefixes(class_name)
342
+ return [] if class_name.nil?
343
+
344
+ segments = class_name.split("::")
345
+ (1..segments.size).map { |n| segments.first(n).join("::") }
346
+ end
347
+
348
+ def bounded_generic?(loader, name)
349
+ params = loader.class_declaration_type_params(name)
350
+ !params.empty? && !params.all? { |param| plain_type_param?(param) }
351
+ end
352
+
353
+ # A name, a variance and `unchecked`, and nothing that spells a type: no upper or lower bound, no default.
354
+ def plain_type_param?(param)
355
+ %i[upper_bound lower_bound default_type].none? do |reader|
356
+ param.respond_to?(reader) && param.public_send(reader)
357
+ end
358
+ end
359
+
224
360
  # Issue #744 — a base class's method is NOT emitted when a project subclass overrides it and the
225
361
  # override is not emitted itself.
226
362
  #
@@ -239,7 +375,7 @@ module Rigor
239
375
  # (#744 half 2) and is not decided here: this pass only stops sig-gen manufacturing the conflict.
240
376
  def demote_overridden_base_methods(candidates)
241
377
  superclasses = candidates.each_with_object({}) do |candidate, acc|
242
- (candidate.class_superclasses || {}).each { |name, sup| acc[name] = sup.sub(/\[.*\]\z/, "") }
378
+ (candidate.class_superclasses || {}).each { |name, sup| acc[name] = bare_superclass_name(sup) }
243
379
  end
244
380
  return candidates if superclasses.empty?
245
381
 
@@ -303,6 +439,10 @@ module Rigor
303
439
  def resolve_superclass_spellings(candidates)
304
440
  return candidates if @superclass_nestings.empty?
305
441
 
442
+ # Every shell counts here, written or not: the question is which class Ruby means by the token, and the
443
+ # source declares `Lib::Error` whether or not its file is written. Whether the resolved superclass reaches
444
+ # disk is {#demote_unresolvable_superclasses}'s question. Counting only written shells would leave `Error`
445
+ # unresolved, and a top-level `Error` some signature declares would then take it (#2454).
306
446
  known = candidates.each_with_object(Set.new) do |candidate, acc|
307
447
  acc << candidate.class_name if candidate.class_name
308
448
  acc.merge(candidate.class_shells || [])
@@ -322,20 +462,32 @@ module Rigor
322
462
  @superclass_nestings.each_with_object({}) do |(full, (nesting, token)), acc|
323
463
  next if token.nil? || nesting.empty?
324
464
 
325
- resolved = resolve_superclass_token(token, nesting, known)
465
+ resolved = resolve_superclass_token(token, nesting, known, full)
326
466
  acc[full] = resolved if resolved && resolved != token
327
467
  end
328
468
  end
329
469
 
330
470
  # The first candidate spelling that names a class this run declares or the RBS environment knows, or
331
- # nil when none does — an unresolvable superclass is #735's business, not this pass's.
332
- def resolve_superclass_token(token, nesting, known)
471
+ # the token itself when none does — an unresolvable superclass is #735's business, not this pass's.
472
+ #
473
+ # `full` is the class whose header carries the token. Ruby evaluates the superclass expression before the
474
+ # new constant exists, so the innermost candidate `full` itself (`module A; module C; class B < B`, where
475
+ # `A::C::B` is the class being opened) is not yet a candidate: the lookup falls through to `A::B` (#2579).
476
+ def resolve_superclass_token(token, nesting, known, full)
477
+ # A rooted token (`::Hash`) already names one class; the nesting must not capture it (#2459).
478
+ return token if token.start_with?("::")
479
+
333
480
  base = token.sub(/\[.*\]\z/, "")
334
- args = token[base.length..] || ""
335
481
  nesting.length.downto(1) do |i|
336
482
  candidate = "#{nesting[0, i].join('::')}::#{base}"
337
- return "#{candidate}#{args}" if known.include?(candidate) ||
338
- Reflection.rbs_class_known?(candidate, environment: @environment)
483
+ next if candidate == full
484
+
485
+ # The arguments {#record_superclass} computed belong to the top-level class of the bare name, so they are
486
+ # computed again for the class the token resolves to: `Hash` inside a `module Foo` declaring a plain
487
+ # `Foo::Hash` takes none (#2459).
488
+ return generic_superclass_spelling(candidate) if known.include?(candidate) ||
489
+ Reflection.rbs_class_known?(candidate,
490
+ environment: @environment)
339
491
  end
340
492
  token
341
493
  end
@@ -368,28 +520,77 @@ module Rigor
368
520
  # ran — source discovery and the plugins still type it. Emitting the class with the superclass merely
369
521
  # OMITTED would be the harmful middle: the class becomes RBS-known with only the emitted methods, so
370
522
  # every inherited call fires `call.undefined-method`.
523
+ #
524
+ # Demoting a class can leave a file with nothing to write, which takes the class shells riding on it off the
525
+ # emitted set, so the pass repeats until no further class becomes unresolvable. The emitted set only shrinks,
526
+ # so the unresolvable set only grows and the loop ends.
371
527
  def demote_unresolvable_superclasses(candidates)
372
528
  superclasses = candidates.each_with_object({}) do |candidate, acc|
373
529
  (candidate.class_superclasses || {}).each { |name, sup| acc[name] = sup }
374
530
  end
375
- # Everything this run declares, superclass or not: a chain that reaches `class Record` — emitted
376
- # here, with no superclass of its own — terminates in a class the next run WILL know.
377
- emitted = candidates.each_with_object(Set.new) do |candidate, acc|
378
- acc << candidate.class_name if candidate.class_name
379
- acc.merge(candidate.class_shells || [])
531
+ current = candidates
532
+ unresolvable = {}
533
+ loop do
534
+ found = unresolvable_superclass_roots(superclasses, declared_by_run(current))
535
+ return current if found.size == unresolvable.size
536
+
537
+ unresolvable = found
538
+ current = candidates.map do |candidate|
539
+ owner = unresolvable_for(candidate.class_name, unresolvable)
540
+ owner ? record_unresolvable_superclass(candidate, owner) : candidate
541
+ end
380
542
  end
381
- unresolvable = superclasses.each_with_object({}) do |(name, _sup), acc|
543
+ end
544
+
545
+ def unresolvable_superclass_roots(superclasses, emitted)
546
+ superclasses.each_with_object({}) do |(name, _sup), acc|
382
547
  root = unresolved_superclass_root(name, superclasses, emitted)
383
548
  acc[name] = root if root
384
549
  end
385
- return candidates if unresolvable.empty?
386
-
387
- candidates.map do |candidate|
388
- owner = unresolvable_for(candidate.class_name, unresolvable)
389
- next candidate if owner.nil?
550
+ end
390
551
 
391
- record_unresolvable_superclass(candidate, owner)
552
+ # Everything this run puts on disk, superclass or not: a chain that reaches `class Record` — emitted here, with
553
+ # no superclass of its own — terminates in a class the next run WILL know. Only what the writer is sure to
554
+ # write counts, because a subclass written against a class nothing declares fails its build
555
+ # (`RBS::NoSuperclassFoundError`) on the next run:
556
+ #
557
+ # - a class with a candidate the writer is sure to write ({#written?}). A class whose every method is skipped
558
+ # is not written, so it does not count (#2437); a class shell on it would close it instead (#2438);
559
+ # - a class shell on a file with such a candidate: a shell on a file whose every method is skipped, already
560
+ # declared by `sig/`, or a tighter return the writer will not apply never reaches disk (#2454);
561
+ # - the wrapper namespaces of either, which the writer renders around them with their recorded superclasses.
562
+ #
563
+ # Counting too few only demotes a subclass, the output before #2454 (ADR-5).
564
+ def declared_by_run(candidates)
565
+ written = candidates.select { |candidate| written?(candidate) }
566
+ written_paths = written.to_set(&:path)
567
+ declared = Set.new
568
+ written.each { |candidate| declared << candidate.class_name if candidate.class_name }
569
+ candidates.each do |candidate|
570
+ declared.merge(candidate.class_shells || []) if written_paths.include?(candidate.path)
392
571
  end
572
+ declared.flat_map { |name| wrapper_names(name) }.to_set | declared
573
+ end
574
+
575
+ # `A::B::C` is written inside `A` and `A::B`.
576
+ def wrapper_names(name)
577
+ segments = name.split("::")
578
+ (1...segments.size).map { |n| segments.first(n).join("::") }
579
+ end
580
+
581
+ # Whether the writer will put `candidate` on disk: a candidate it is sure to write ({Writer.writes?}), and,
582
+ # when the run writes or compares files (`--write`, `--check`), aimed at a target that writer will write to
583
+ # (#2461). Print, diff and JSON runs have no writer: where a file would go depends on the working directory
584
+ # the writer takes its sig root from, and their output must not.
585
+ def written?(candidate)
586
+ return false unless Writer.writes?(candidate)
587
+ return true if @target_writer.nil?
588
+
589
+ target = @target_writer.path_mapper.target_for(candidate.path, class_name: candidate.class_name,
590
+ method_name: candidate.method_name,
591
+ kind: candidate.rbs_kind)
592
+ @writable_targets[target] = @target_writer.accepts_target?(target) unless @writable_targets.key?(target)
593
+ @writable_targets[target]
393
594
  end
394
595
 
395
596
  # A nested class or module goes with its wrapper. `WikiPage::Webhookable`'s declaration is emitted
@@ -415,9 +616,9 @@ module Rigor
415
616
  return superclass if seen[name]
416
617
 
417
618
  seen[name] = true
418
- # The recorded spelling may carry the type arguments {#generic_superclass_spelling} added; the
419
- # resolvability question is about the class, so it is asked of the bare name.
420
- base = superclass.sub(/\[.*\]\z/, "")
619
+ # The recorded spelling may carry the type arguments {#generic_superclass_spelling} added, and a rooted
620
+ # token its `::` (#2459); the resolvability question is about the class, so it is asked of the bare name.
621
+ base = bare_superclass_name(superclass)
421
622
  return nil if Reflection.rbs_class_known?(base, environment: @environment)
422
623
  return superclass unless emitted.include?(base)
423
624
 
@@ -458,22 +659,65 @@ module Rigor
458
659
  end.uniq
459
660
  end
460
661
 
662
+ # #1154 / #2300 — the project's discovery tables, built once per run over the configured project paths as well
663
+ # as the files sig-gen reads, so a run over one file reads what a `check` of it reads. Every file is indexed
664
+ # over them ({#file_base_scope}): a helper, an ancestor or a constant another file defines resolves as it does
665
+ # under `check` instead of reading ABSENT. The declared-parameter ivar credit reads them whole
666
+ # ({#ivar_writer_scope}).
667
+ def project_tables
668
+ @project_tables ||= Inference::ProjectSeedTables.for_paths(
669
+ ((@resolved_paths || []) + resolve_paths(Array(@configuration.paths))).uniq { |path| File.expand_path(path) }
670
+ )
671
+ end
672
+
673
+ # The member layouts stay per-file. sig-gen writes a value class for every `Data.define` / `Struct.new` layout
674
+ # the indexed scope carries ({#collect_meta_layouts}), so a seeded layout would re-emit every value class of the
675
+ # project in every file.
676
+ PER_FILE_TABLES = %i[data_member_layouts struct_member_layouts].freeze
677
+ private_constant :PER_FILE_TABLES
678
+
679
+ # #1154 — the declared-parameter ivar credit must see the ivar writers in other files (an ancestor, another
680
+ # object's `instance_variable_set`, a reopening) as `rigor check` does.
681
+ def ivar_writer_scope(base_scope)
682
+ base_scope.with_discovery(base_scope.discovery.with(**project_tables))
683
+ end
684
+
685
+ # #1406 — sig-gen keeps the census's own answer: a declared ivar type read back would echo the declaration.
686
+ def index_file(root, base_scope)
687
+ Inference::ScopeIndexer.index(
688
+ root, default_scope: base_scope, ivar_writer_scope: ivar_writer_scope(base_scope), declared_ivar_types: false
689
+ )
690
+ end
691
+
692
+ # The scope each file is indexed under: the project's tables less the per-file ones ({#project_tables}), and the
693
+ # run's return-memo token (see {#run}), which {#ivar_writer_scope} inherits, so every reading of a callee shares
694
+ # one memo bucket. Built once per run.
695
+ def file_base_scope(environment)
696
+ @file_base_scope ||= begin
697
+ base = Scope.empty(environment: environment)
698
+ tables = project_tables.except(*PER_FILE_TABLES)
699
+ tables = tables.merge(run_generation: @run_generation) if @run_generation
700
+ base.with_discovery(base.discovery.with(**tables))
701
+ end
702
+ end
703
+
461
704
  def analyse_file(path, environment)
462
705
  source = File.read(path)
463
706
  parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
464
707
  return [] if parse_result.errors.any?
465
708
 
466
- base_scope = Scope.empty(environment: environment)
467
- scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
709
+ scope_index = index_file(parse_result.value, file_base_scope(environment))
468
710
 
469
711
  reset_file_maps
470
712
  @meta_layouts = collect_meta_layouts(scope_index)
471
713
  register_meta_classes
472
714
  defs = collect_method_definitions(parse_result.value, scope_index)
715
+ definers = definer_table(parse_result.value, defs)
716
+ defs = live_definitions(defs, definers)
473
717
  # Candidate construction freezes the per-file maps above (see {#build_candidate}), so every registration
474
718
  # pass has to be finished before the first `build_candidate` call. Meta members lead the RETURNED order —
475
719
  # a value class's members and constructors read first, ahead of the methods its block body defines.
476
- meta_candidates = collect_meta_member_candidates(path, scope_index)
720
+ meta_candidates = live_meta_candidates(path, scope_index, definers)
477
721
  candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
478
722
  # An analyzer bug typing one def's body must cost only that def's candidate, never the whole
479
723
  # `rigor sig-gen` run. The `check` path recovers each *file* this way (worker_session.rb); sig-gen
@@ -485,13 +729,109 @@ module Rigor
485
729
  end
486
730
  obs_ivar_map = build_observed_ivar_map(parse_result.value)
487
731
  meta_candidates + candidates_from_defs +
488
- collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
732
+ collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map, definers)
733
+ end
734
+
735
+ # Ruby keeps the LAST definition of a name: a `def x` followed by `attr_reader :x` (or the reverse) defines
736
+ # `x` once, and a second `def x` after a `module_function` reset replaces only the instance method. Writing
737
+ # one entry per definition gave RBS a duplicate method definition (#2008, #2023), so every definer of a
738
+ # `(class, kind, name)` slot is recorded with its source offset, and only the last one in a file is written.
739
+ # A module function defines two slots, its singleton copy and its instance method. A `Data.define` or
740
+ # `Struct.new` member is defined before any body that could redefine it, so it ranks first.
741
+ #
742
+ # Only a definer whose statement certainly runs can be last: a direct statement of a `class`, `module` or
743
+ # `class << self` body, of a `Data.define` / `Struct.new` block or of the file ({#walk_statements} records
744
+ # them), or a `def` passed to `private`, `public` or `protected` in such a statement ({#walk_statements}, #2254).
745
+ # One under a condition, in a block or a lambda, or after an uncertain `module_function` reset may not run, as
746
+ # far as this reading tells. A slot such a definer could
747
+ # still take over from the last certain one is contested, and nothing is written for it.
748
+ #
749
+ # @return the definers of each slot as `[offset, certain]` pairs, keyed by `[class_name, kind, method_name]`.
750
+ MEMBER_OFFSET = -1
751
+ private_constant :MEMBER_OFFSET
752
+
753
+ def definer_table(root, defs)
754
+ certain = @certain_definers
755
+ table = Hash.new { |h, k| h[k] = [] }
756
+ record_def_definers(defs, certain, table)
757
+ meta_member_slots.each { |slot| table[slot] << [MEMBER_OFFSET, true] }
758
+ ctx = AttrWalkContext.new(definers: [])
759
+ walk_attr_calls(root, [], false, ctx)
760
+ ctx.definers.each do |slot, call_node|
761
+ table[slot] << [call_node.location.start_offset, certain.include?(call_node)]
762
+ end
763
+ table
764
+ end
765
+
766
+ def record_def_definers(defs, certain, table)
767
+ defs.each do |def_node, class_name, kind|
768
+ next if class_name.nil?
769
+
770
+ definer_slots(def_node, class_name, kind).each do |slot, possible_only|
771
+ table[slot] << [def_node.location.start_offset, !possible_only && certain.include?(def_node)]
772
+ end
773
+ end
774
+ end
775
+
776
+ def live_meta_candidates(path, scope_index, table)
777
+ collect_meta_member_candidates(path, scope_index).select do |c|
778
+ live_definer?(table, [c.class_name, c.kind, c.method_name], MEMBER_OFFSET)
779
+ end
780
+ end
781
+
782
+ # `[slot, possible_only]` pairs. A `def` after an uncertain `module_function` reset may or may not install a
783
+ # singleton copy, so it is a possible definer of that slot only.
784
+ def definer_slots(def_node, class_name, kind)
785
+ slots = [[[class_name, kind, def_node.name], false]]
786
+ if @module_function_methods.include?(def_node)
787
+ slots << [[class_name, :singleton, def_node.name], false]
788
+ elsif @uncertain_module_function_defs.include?(def_node)
789
+ slots << [[class_name, :singleton, def_node.name], true]
790
+ end
791
+ slots
792
+ end
793
+
794
+ def meta_member_slots
795
+ @meta_layouts.flat_map do |class_name, layout|
796
+ MetaClassShape.of(kind: layout.kind, members: layout.member_names, keyword_init: layout.keyword_init)
797
+ .member_decls.map { |member| [class_name, member.kind, member.method_name] }
798
+ end
799
+ end
800
+
801
+ # Whether the definer at `offset` is the one Ruby keeps for `slot`. A slot with a single definer keeps it,
802
+ # certain or not, as before.
803
+ def live_definer?(table, slot, offset)
804
+ entries = table.fetch(slot, [])
805
+ return true if entries.size <= 1
806
+
807
+ last_certain = entries.select { |_, certain| certain }.map(&:first).max
808
+ return false if last_certain.nil?
809
+ return false if entries.any? { |entry_offset, certain| !certain && entry_offset > last_certain }
810
+
811
+ offset == last_certain
812
+ end
813
+
814
+ # The `defs` whose definition Ruby keeps. A module function whose instance method a later definition
815
+ # replaced keeps only its singleton copy (`M.label` still returns the first body), so it is written as
816
+ # `def self.label`. One whose singleton copy was replaced keeps only a private instance method, which
817
+ # sig-gen does not write, so it is dropped.
818
+ def live_definitions(defs, table)
819
+ defs.filter_map do |def_node, class_name, kind|
820
+ next [def_node, class_name, kind] if class_name.nil?
821
+
822
+ slots = definer_slots(def_node, class_name, kind).reject(&:last).map(&:first)
823
+ live = slots.select { |slot| live_definer?(table, slot, def_node.location.start_offset) }
824
+ next [def_node, class_name, kind] if live.size == slots.size
825
+ next nil unless live == [[class_name, :singleton, def_node.name]]
826
+
827
+ [def_node, class_name, :singleton]
828
+ end
489
829
  end
490
830
 
491
831
  # The per-file scratch state {#analyse_file} starts each file with.
492
832
  def reset_file_maps
493
833
  @namespace_kinds = {}
494
- @module_function_methods = Set.new
834
+ @module_function_methods = Set.new.compare_by_identity
495
835
  @uncertain_module_function_defs = Set.new.compare_by_identity
496
836
  @class_shells = Set.new
497
837
  @class_superclasses = {}
@@ -509,15 +849,19 @@ module Rigor
509
849
  # - `@namespace_kinds[qualified_name]` records whether each segment came from `class Foo` (`:class`) or
510
850
  # `module Foo` (`:module`). Used by the writer's `wrap_in_modules` step to emit the right keyword for
511
851
  # each intermediate segment AND the leaf.
512
- # - `@module_function_methods` records `(class_name, method_name)` pairs where a `module_function` (no
513
- # args) call preceded the `def` inside a module body. The renderer emits `def self?.name` for these, the
514
- # RBS spelling that matches the dual instance + singleton dispatch the runtime produces.
852
+ # - `@module_function_methods` records the `def` nodes a `module_function` (no args) call preceded inside
853
+ # a module body. The renderer emits `def self?.name` for these, the RBS spelling that matches the dual
854
+ # instance + singleton dispatch the runtime produces. It is keyed by node, not by name: a later `def` of
855
+ # the same name after a reset is an ordinary method (#2023).
515
856
  def collect_method_definitions(root, scope_index)
516
857
  @declared_types = scope_index[root].declared_types
517
858
  @walk_root = root
518
859
  @in_effect_refinements = nil
519
860
  out = []
520
861
  @block_depth = 0
862
+ @certain_bodies = Set.new.compare_by_identity
863
+ @certain_definers = Set.new.compare_by_identity
864
+ mark_certain_body(root.statements)
521
865
  walk_defs(root, [], false, false, out)
522
866
  out
523
867
  end
@@ -530,6 +874,7 @@ module Rigor
530
874
  return if descend_into_namespace?(node, prefix, out)
531
875
  when Prism::SingletonClassNode
532
876
  if node.expression.is_a?(Prism::SelfNode) && node.body
877
+ mark_certain_body(node.body)
533
878
  walk_defs(node.body, prefix, true, false, out)
534
879
  return
535
880
  end
@@ -539,6 +884,7 @@ module Rigor
539
884
  when Prism::ConstantWriteNode
540
885
  body = meta_block_body(node, prefix)
541
886
  if body
887
+ mark_certain_body(body)
542
888
  walk_defs(body, prefix + [node.name.to_s], false, false, out)
543
889
  return
544
890
  end
@@ -555,6 +901,9 @@ module Rigor
555
901
  # the `self` one returns above): it defines a singleton method on `obj`, not a private `Object` method.
556
902
  def walk_def_children(node, prefix, in_singleton_class, module_function_active, out)
557
903
  return if refine_block_call?(node)
904
+ # #2259 — a `def` in `class_methods do … end` (ActiveSupport::Concern) defines a method of the including class's
905
+ # singleton, not of this module. Omitted, not written class-side: no precedent places it on the singleton.
906
+ return if class_methods_block?(node)
558
907
 
559
908
  in_block = node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode) ||
560
909
  node.is_a?(Prism::SingletonClassNode)
@@ -584,6 +933,7 @@ module Rigor
584
933
 
585
934
  full = child_prefix.join("::")
586
935
  @namespace_kinds[full] = node.is_a?(Prism::ClassNode) ? :class : :module
936
+ @source_classes << full if node.is_a?(Prism::ClassNode)
587
937
  record_superclass(node, full, prefix)
588
938
  walk_namespace_body(node, child_prefix, out)
589
939
  true
@@ -603,6 +953,17 @@ module Rigor
603
953
  !@in_effect_refinements.class_body_refine?(node)
604
954
  end
605
955
 
956
+ # A block call whose members are not this class body's: a `refine` block (#1763), and a `class_methods` block
957
+ # (#2259), whose members belong to the including class's singleton.
958
+ def skipped_block_call?(node)
959
+ refine_block_call?(node) || class_methods_block?(node)
960
+ end
961
+
962
+ def class_methods_block?(node)
963
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :class_methods &&
964
+ node.block.is_a?(Prism::BlockNode)
965
+ end
966
+
606
967
  def declined_self_header?(node)
607
968
  path = node.constant_path
608
969
  path.is_a?(Prism::ConstantPathNode) && path.parent.is_a?(Prism::SelfNode) &&
@@ -628,10 +989,43 @@ module Rigor
628
989
  # the name it resolves to may be a class another file declares. Resolved once every file has been
629
990
  # walked, in {#resolve_superclass_spellings}.
630
991
  spelling = generic_superclass_spelling(superclass)
992
+ # Issue #2579 — a token that names the class being opened (`class A::B < A::B`, or a top-level `class B < B`
993
+ # with nothing else of that name) would write a self-superclass, which RBS rejects as a recursive ancestor.
994
+ # No superclass is safer than that, so the declaration is written without one.
995
+ return if bare_superclass_name(spelling) == full
996
+
631
997
  # The token rides along because `@class_superclasses` is per-FILE scratch, reset before the next
632
998
  # file, while this table is whole-run.
633
999
  @superclass_nestings[full] = [prefix.dup, spelling]
634
1000
  @class_superclasses[full] = spelling
1001
+ register_core_subclass_shell(full, prefix, superclass)
1002
+ end
1003
+
1004
+ # Issue #2224 — a subclass of a core or stdlib class got a declaration only when it had a method of its own to
1005
+ # emit, so after `class MyHash < Hash; end` a method returning one was written `-> MyHash` against a class no
1006
+ # signature declared, and `rbs validate` failed with `NoTypeFoundError`. As a class shell it is declared
1007
+ # `class MyHash < ::Hash[untyped, untyped]` beside the file's other declarations. A class the RBS environment
1008
+ # already declares (the project's `sig/`, an earlier run) is left to that declaration, and a superclass the
1009
+ # header's nesting shadows is not core's: `Hash` inside a `module Foo` that declares `Foo::Hash` is the project's.
1010
+ # A rooted `::Hash` names core's class whatever the nesting declares, so the shadow cannot capture it (#2459).
1011
+ def register_core_subclass_shell(full, prefix, superclass)
1012
+ return if class_known?(full)
1013
+
1014
+ bare = bare_superclass_name(superclass)
1015
+ return unless @environment&.rbs_loader&.core_or_stdlib_class?(bare)
1016
+
1017
+ unless superclass.start_with?("::")
1018
+ namespaces = TypeNameQualification.enclosing_namespaces(prefix.join("::"))
1019
+ return if TypeNameQualification.shadowed?(bare.split("::").first, namespaces, method(:declared_constant?))
1020
+ end
1021
+
1022
+ @class_shells << full
1023
+ end
1024
+
1025
+ # The class a recorded superclass token names, as the fully qualified key every class-name set and the RBS
1026
+ # loader use: the type arguments {#generic_superclass_spelling} added and a rooted token's `::` both dropped.
1027
+ def bare_superclass_name(token)
1028
+ token.sub(/\[.*\]\z/, "").delete_prefix("::")
635
1029
  end
636
1030
 
637
1031
  # Issue #735 — a GENERIC superclass has to be written with its arguments. `class Entries < Array`
@@ -644,7 +1038,7 @@ module Rigor
644
1038
  loader = @environment&.rbs_loader
645
1039
  return superclass if loader.nil?
646
1040
 
647
- arity = loader.class_type_param_names(superclass).size
1041
+ arity = loader.class_type_param_names(bare_superclass_name(superclass)).size
648
1042
  return superclass if arity.zero?
649
1043
 
650
1044
  "#{superclass}[#{(['untyped'] * arity).join(', ')}]"
@@ -692,6 +1086,7 @@ module Rigor
692
1086
  @meta_layouts.each do |class_name, layout|
693
1087
  @namespace_kinds[class_name] = :class
694
1088
  @class_shells << class_name
1089
+ @source_classes << class_name
695
1090
  @class_superclasses[class_name] = MetaClassShape::SUPERCLASSES.fetch(layout.kind)
696
1091
  end
697
1092
  end
@@ -717,13 +1112,32 @@ module Rigor
717
1112
  def walk_namespace_body(namespace_node, prefix, out)
718
1113
  return if namespace_node.body.nil?
719
1114
 
1115
+ mark_certain_body(namespace_node.body)
720
1116
  walk_defs(namespace_node.body, prefix, false, false, out)
721
1117
  end
722
1118
 
1119
+ # A body whose direct statements certainly run when it does ({#definer_table}): its statement list unless the
1120
+ # body has a `rescue` clause, and its `ensure` clause. A `rescue` or `else` clause may not run.
1121
+ def mark_certain_body(body)
1122
+ return @certain_bodies << body unless body.is_a?(Prism::BeginNode)
1123
+
1124
+ # With a `rescue`, a statement before a definer can raise and skip it, so the main list is not certain.
1125
+ @certain_bodies << body.statements if body.statements && body.rescue_clause.nil?
1126
+ @certain_bodies << body.ensure_clause.statements if body.ensure_clause&.statements
1127
+ end
1128
+
723
1129
  # The directive's reach is {Inference::ModuleFunctionState.each_sig_gen_statement}.
724
1130
  def walk_statements(stmts_node, prefix, in_singleton_class, module_function_active, out)
1131
+ certain_body = @certain_bodies.include?(stmts_node)
725
1132
  Inference::ModuleFunctionState.each_sig_gen_statement(stmts_node.body,
726
1133
  module_function_active) do |stmt, active|
1134
+ if certain_body
1135
+ @certain_definers << stmt
1136
+ # A `begin … end` statement's body (and `ensure`) runs whenever the statement does.
1137
+ mark_certain_body(stmt) if stmt.is_a?(Prism::BeginNode)
1138
+ # A `def` passed to a visibility call (`private def x`, #2254) runs with its statement.
1139
+ @certain_definers.merge(Inference::ModuleFunctionState.visibility_call_defs(stmt))
1140
+ end
727
1141
  walk_defs(stmt, prefix, in_singleton_class, active, out)
728
1142
  end
729
1143
  end
@@ -740,7 +1154,7 @@ module Rigor
740
1154
  kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
741
1155
  class_name = prefix.join("::")
742
1156
  if Inference::ModuleFunctionState.sig_gen_module_function?(module_function_active, kind)
743
- @module_function_methods << [class_name, node.name]
1157
+ @module_function_methods << node
744
1158
  elsif Inference::ModuleFunctionState.sig_gen_uncertain?(module_function_active, kind)
745
1159
  @uncertain_module_function_defs << node
746
1160
  end
@@ -782,9 +1196,9 @@ module Rigor
782
1196
 
783
1197
  # Returns "def self." (kind: :singleton), "def self?." (instance method declared inside a
784
1198
  # `module_function` region — both instance + singleton dispatch at runtime), or "def " (plain instance).
785
- def method_def_prefix(class_name, method_name, kind)
1199
+ def method_def_prefix(def_node, kind)
786
1200
  return "def self." if kind == :singleton
787
- return "def self?." if @module_function_methods.include?([class_name, method_name])
1201
+ return "def self?." if @module_function_methods.include?(def_node)
788
1202
 
789
1203
  "def "
790
1204
  end
@@ -800,7 +1214,7 @@ module Rigor
800
1214
  def visibility_excludes?(def_node, class_name, kind, scope_index)
801
1215
  return false if kind == :singleton
802
1216
  return false if @include_private
803
- return false if @module_function_methods.include?([class_name, def_node.name])
1217
+ return false if @module_function_methods.include?(def_node)
804
1218
 
805
1219
  scope = scope_index[def_node] || scope_index.each_value.first
806
1220
  return false if scope.nil?
@@ -820,8 +1234,8 @@ module Rigor
820
1234
  # `Object#initialize: () -> void`. The mismatch surfaces a `Ruby::MethodParameterMismatch` warning even
821
1235
  # when `rigor check` itself is clean.
822
1236
  #
823
- # Returning `nil` here causes `classify_def` to skip emission; returning `:emit_stub` causes
824
- # `initialize_stub_candidate` to emit a permissive `(<param shape>) -> void` stub matching the runtime
1237
+ # A default constructor is skipped (`classify_def` returns nil for it); a non-trivial one is not, and
1238
+ # {#initialize_stub_candidate} emits a permissive `(<param shape>) -> void` stub matching the runtime
825
1239
  # parameter list.
826
1240
  def initialize_excludes?(def_node, kind)
827
1241
  return false unless kind == :instance
@@ -949,25 +1363,36 @@ module Rigor
949
1363
  # return position it is not parenthesised.
950
1364
  def positional_type(observations, index, prefix, owner = nil)
951
1365
  types = observations.filter_map { |obs| obs.positional[index] }
952
- "#{prefix}#{union_erase(types, owner: owner)}"
1366
+ "#{prefix}#{union_erase(widen_observed_params(types), owner: owner)}"
953
1367
  end
954
1368
 
955
1369
  def render_keyword_param(keyword, observations, owner = nil)
956
1370
  optional_marker = keyword.is_a?(Prism::OptionalKeywordParameterNode) ? "?" : ""
957
1371
  types = observations.filter_map { |obs| obs.keyword[keyword.name] }
958
- rendered = types.empty? ? "untyped" : paren_wrap_union(union_erase(types, owner: owner))
1372
+ rendered = types.empty? ? "untyped" : paren_wrap_union(union_erase(widen_observed_params(types), owner: owner))
959
1373
  "#{optional_marker}#{keyword.name}: #{rendered}"
960
1374
  end
961
1375
 
1376
+ # #2254 — an observed argument is one call's value, so a literal (`"hello"`) proposes its class (`String`), as
1377
+ # {#widen_value_literals} does for a return. Widening always: a parameter the body only compares against a
1378
+ # literal (a discriminant) is widened too, which loses precision and never adds a false positive.
1379
+ def widen_observed_params(types)
1380
+ types.map { |type| widen_value_literals(type) }
1381
+ end
1382
+
1383
+ # Issue #2459 — a ROOTED path (`::Hash`, `::A::B`) keeps its leading `::`: it names exactly one class, and the
1384
+ # passes after {#record_superclass} must not re-resolve it against the header's nesting, where a project
1385
+ # `Foo::Hash` would capture it. Every pass that compares the token against class names strips the anchor.
962
1386
  def qualified_constant_path(constant_path)
963
1387
  case constant_path
964
1388
  when Prism::ConstantReadNode
965
1389
  constant_path.name.to_s
966
1390
  when Prism::ConstantPathNode
967
- parent = qualified_constant_path(constant_path.parent) if constant_path.parent
968
1391
  name = constant_path.name&.to_s
969
1392
  return nil if name.nil?
1393
+ return "::#{name}" if constant_path.parent.nil?
970
1394
 
1395
+ parent = qualified_constant_path(constant_path.parent)
971
1396
  parent ? "#{parent}::#{name}" : name
972
1397
  end
973
1398
  end
@@ -1038,7 +1463,7 @@ module Rigor
1038
1463
 
1039
1464
  scope = scope_index[def_node]
1040
1465
  method_def = lookup_existing_method(class_name, def_node.name, kind, scope&.environment, scope)
1041
- head = "#{method_def_prefix(class_name, def_node.name, kind)}#{def_node.name}: "
1466
+ head = "#{method_def_prefix(def_node, kind)}#{def_node.name}: "
1042
1467
  if return_defaulted
1043
1468
  return mixed_provenance_candidate(path, def_node, class_name, kind, scope_index, inline, method_def, head)
1044
1469
  end
@@ -1258,7 +1683,37 @@ module Rigor
1258
1683
  # different method (issue #1382). Delegates to {Rigor::Inference::DefReturnTyper} — the same body-typing +
1259
1684
  # explicit-return-union the `rigor annotate` def-line annotator uses.
1260
1685
  def infer_return_type(def_node, scope_index)
1261
- Inference::DefReturnTyper.call(def_node, scope_index)
1686
+ absorb_empty_literals(Inference::DefReturnTyper.call(def_node, scope_index, keep_lookup_nil: true))
1687
+ end
1688
+
1689
+ # Issue #1153 — an empty-collection early return (`return [] if table.empty?`) types its arm `[]`, which the
1690
+ # engine's union keeps beside a sibling `Array[E]` arm on purpose (the empty tuple is a distinct shape). A
1691
+ # signature has no use for the distinction: `[]` is an `Array[E]` for every `E`, so `Array[E] | []` is just
1692
+ # `Array[E]`, and the leftover `[]` arm made the proposal read as if the empty literal pinned the element.
1693
+ # Issue #1149 — the same holds for the empty closed hash shape `{}` (a frozen empty fallback,
1694
+ # `table[key] || EMPTY`) beside a `Hash[K, V]` nominal. Each empty literal is absorbed only beside its OWN
1695
+ # nominal: a `[]` beside a `Hash` arm, or a `{}` beside an `Array` arm, is untouched. A lone empty literal,
1696
+ # one beside another tuple / shape (`[1, 2] | []`), an open `{ ... }` shape, and a non-empty shape are
1697
+ # untouched too, since each of those is information the declaration should keep.
1698
+ def absorb_empty_literals(type)
1699
+ return type unless type.is_a?(Type::Union)
1700
+
1701
+ kept = type.members.reject { |member| absorbed_empty_literal?(member, type.members) }
1702
+ kept.size == type.members.size ? type : Type::Combinator.union(*kept)
1703
+ end
1704
+
1705
+ def absorbed_empty_literal?(member, members)
1706
+ if member.is_a?(Type::Tuple) && member.elements.empty?
1707
+ nominal_arm?(members, "Array")
1708
+ elsif member.is_a?(Type::HashShape) && member.pairs.empty? && member.closed?
1709
+ nominal_arm?(members, "Hash")
1710
+ else
1711
+ false
1712
+ end
1713
+ end
1714
+
1715
+ def nominal_arm?(members, class_name)
1716
+ members.any? { |member| member.is_a?(Type::Nominal) && member.class_name == class_name }
1262
1717
  end
1263
1718
 
1264
1719
  def dynamic_top?(type)
@@ -1305,7 +1760,7 @@ module Rigor
1305
1760
  return equivalent(path, def_node, class_name, kind, inferred, VOID_RETURN_RBS) if declares_void?(method_def)
1306
1761
 
1307
1762
  declared = build_declared_return(method_def)
1308
- if declared_untyped?(declared)
1763
+ if declared_untyped?(declared, method_def)
1309
1764
  return declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: rendered)
1310
1765
  end
1311
1766
 
@@ -1351,16 +1806,16 @@ module Rigor
1351
1806
  # about the return. The class's own declaration is kept whole instead: its visibility, the overload's
1352
1807
  # annotations, type parameters, parameters and block, with only the return replaced.
1353
1808
  #
1354
- # Three shapes are declined (`nil`):
1809
+ # Two shapes are declined (`nil`):
1355
1810
  # - several overloads. The body is typed once, so the inferred return covers every overload together and
1356
1811
  # says nothing about which overload returns what: collapsing them into one line would drop the
1357
1812
  # overloads, and giving each the joint return would widen the overloads the author wrote narrower.
1358
1813
  # - a name the class's own `sig/` spells as an `alias`. Its parameters are the aliased method's, and a
1359
1814
  # proposal would read as a rewrite of that method's line rather than of the alias. An alias only an
1360
1815
  # ancestor declares takes the override path below, like any other ancestor declaration.
1361
- # - nothing else: when only an ancestor declares the method, the proposal is a new override on this
1362
- # class, and is rendered from the `def`'s own shape like any new method. The ancestor's parameters
1363
- # describe the ancestor's `def`, not this one, which may take other arguments (ADR-14 clause 2).
1816
+ # A method this class does not declare is not declined: when only an ancestor declares it, the proposal is a
1817
+ # new override on this class, rendered from the `def`'s own shape like any new method. The ancestor's
1818
+ # parameters describe the ancestor's `def`, not this one, which may take other arguments (ADR-14 clause 2).
1364
1819
  def tighter_return_line(def_node, method_def, class_name, kind, inferred)
1365
1820
  member = signature_member(method_def, class_name)
1366
1821
  return render_rbs_line(def_node, inferred, class_name, kind) if member.nil?
@@ -1372,7 +1827,7 @@ module Rigor
1372
1827
  returned = paren_wrap_union(elaborated_rbs(inferred, owner: class_name))
1373
1828
  annotations = overload.annotations.map { |a| "#{a.location&.source || "%a{#{a.string}}"} " }.join
1374
1829
  visibility = member.respond_to?(:visibility) && member.visibility ? "#{member.visibility} " : ""
1375
- "#{visibility}#{method_def_prefix(class_name, def_node.name, kind)}#{def_node.name}: " \
1830
+ "#{visibility}#{method_def_prefix(def_node, kind)}#{def_node.name}: " \
1376
1831
  "#{annotations}#{spell_with_return(overload.method_type, returned)}"
1377
1832
  end
1378
1833
 
@@ -1431,8 +1886,13 @@ module Rigor
1431
1886
  # skips. ADR-93's inline `# @rbs param: Type` annotation (no return clause) synthesizes exactly this
1432
1887
  # spelling for every method it documents, so the silent drop hit the parameter-annotated, return-bare
1433
1888
  # shape ADR-14 exists to help.
1434
- def declared_untyped?(declared)
1435
- declared.is_a?(Type::Dynamic)
1889
+ #
1890
+ # The translation alone cannot say the author wrote `untyped`: an interface return (`-> _Taker[1]`,
1891
+ # `-> _ToS`) also translates to `Dynamic`, and reading it as an absent declaration replaced it with an
1892
+ # unrelated nominal under `--overwrite` (#2178). So every overload's return must be `untyped` as written.
1893
+ def declared_untyped?(declared, method_def)
1894
+ declared.is_a?(Type::Dynamic) &&
1895
+ method_def.method_types.all? { |mt| mt.type.return_type.is_a?(RBS::Types::Bases::Any) }
1436
1896
  end
1437
1897
 
1438
1898
  # Proposed without {#tighter?}: a declared `untyped` return carries no information to weigh it against, the
@@ -1465,7 +1925,16 @@ module Rigor
1465
1925
  VALUE_LITERAL_CLASSES = [Integer, String, Symbol].freeze
1466
1926
  private_constant :VALUE_LITERAL_CLASSES
1467
1927
 
1468
- # The same object back when nothing needed widening.
1928
+ # Core generics whose RBS parameters are all `out` or `unchecked out`, so a widened argument is a supertype of
1929
+ # the body's type. An invariant or `in` parameter (a user `class Sink[in T]`) is not widened: `Sink[Integer]`
1930
+ # is no supertype of `Sink[1]` there.
1931
+ COVARIANT_CARRIERS = %w[Array Hash Set Range Enumerator].freeze
1932
+ private_constant :COVARIANT_CARRIERS
1933
+
1934
+ # The same object back when nothing needed widening. A literal nested in a tuple element, a record or hash
1935
+ # value, or a covariant generic argument (`[1, "a"]`, `{ a: 1 }`, `Hash[String, 1]`) widens the same way, while
1936
+ # the carrier's shape and a record's keys stay: a subclass override returning `[2, "b"]` is no widening of
1937
+ # `[Integer, String]`. A nested `nil` is an honest member there, as in a top-level union.
1469
1938
  def widen_value_literals(type)
1470
1939
  case type
1471
1940
  when Type::Constant
@@ -1473,14 +1942,35 @@ module Rigor
1473
1942
  return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
1474
1943
 
1475
1944
  Type::Combinator.nominal_of(type.value.class.name)
1476
- when Type::Union
1477
- members = type.members.map { |member| widen_value_literals(member) }
1478
- members.each_with_index.all? { |m, i| m.equal?(type.members[i]) } ? type : Type::Combinator.union(*members)
1945
+ when Type::Union then widen_members(type.members, type) { |members| Type::Combinator.union(*members) }
1946
+ when Type::Tuple then widen_members(type.elements, type) { |elements| Type::Combinator.tuple_of(*elements) }
1947
+ when Type::HashShape then widen_hash_shape_values(type)
1948
+ when Type::Nominal
1949
+ return type unless COVARIANT_CARRIERS.include?(type.class_name)
1950
+
1951
+ widen_members(type.type_args, type) do |args|
1952
+ Type::Combinator.nominal_of(type.class_name, type_args: args)
1953
+ end
1479
1954
  else
1480
1955
  type
1481
1956
  end
1482
1957
  end
1483
1958
 
1959
+ def widen_members(members, type)
1960
+ widened = members.map { |member| widen_value_literals(member) }
1961
+ widened.each_with_index.all? { |m, i| m.equal?(members[i]) } ? type : yield(widened)
1962
+ end
1963
+
1964
+ def widen_hash_shape_values(shape)
1965
+ widen_members(shape.pairs.values, shape) do |values|
1966
+ Type::HashShape.new(
1967
+ shape.pairs.keys.zip(values).to_h,
1968
+ required_keys: shape.required_keys, optional_keys: shape.optional_keys,
1969
+ read_only_keys: shape.read_only_keys, extra_keys: shape.extra_keys
1970
+ )
1971
+ end
1972
+ end
1973
+
1484
1974
  def widen_boolean_literal
1485
1975
  Type::Combinator.union(Type::Combinator.constant_of(true), Type::Combinator.constant_of(false))
1486
1976
  end
@@ -1624,11 +2114,12 @@ module Rigor
1624
2114
  # `Type::Constant#erase_to_rbs` spells the values RBS writes as literal types (`true` / `false` /
1625
2115
  # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as a nominal. Asking
1626
2116
  # which of the two branches it took keeps the list of literal-spellable classes from forking a second
1627
- # copy into this file. A Range's nominal carries its element type (`Range[Integer]`, #1891), so it is
1628
- # excluded by class rather than by comparing the erasure with the class name: like a Float it is a
1629
- # nominal, not a pinned literal.
2117
+ # copy into this file. A Range's and a Set's nominal carries a type argument (`Range[Integer]`, #1891;
2118
+ # `Set[untyped]`, #2177), so they are excluded by class rather than by comparing the erasure with the class
2119
+ # name: like a Float each is a nominal, not a pinned literal.
1630
2120
  def rbs_literal?(type)
1631
- type.is_a?(Type::Constant) && !type.value.is_a?(Range) && type.erase_to_rbs != type.value.class.name
2121
+ type.is_a?(Type::Constant) && !type.value.is_a?(Range) && !type.value.is_a?(::Set) &&
2122
+ type.erase_to_rbs != type.value.class.name
1632
2123
  end
1633
2124
 
1634
2125
  # Heuristic added after the third-round self-dogfood: `FallbackTracer#size` body is `@events.size`, where
@@ -1696,7 +2187,7 @@ module Rigor
1696
2187
  def render_rbs_line(def_node, inferred, class_name, kind)
1697
2188
  params = def_node.parameters
1698
2189
  head = "(#{render_param_list(params, class_name, def_node.name)})#{block_signature_suffix(params)}"
1699
- prefix = method_def_prefix(class_name, def_node.name, kind)
2190
+ prefix = method_def_prefix(def_node, kind)
1700
2191
  "#{prefix}#{def_node.name}: #{head} -> #{paren_wrap_union(elaborated_rbs(inferred, owner: class_name))}"
1701
2192
  end
1702
2193
 
@@ -1707,8 +2198,35 @@ module Rigor
1707
2198
  # `owner:` is the class or module the rendered member belongs to. {AliasIndex} needs it to judge whether
1708
2199
  # a matching alias is one this reader would expect to see; a call site that cannot supply it renders the
1709
2200
  # type unfolded, which is the same output as before #1002.
2201
+ #
2202
+ # Issue #2230 — qualified last, after the alias fold has matched the unqualified spelling it keys on, so a core
2203
+ # name a namespace of the owner shadows (`Set` inside a `module Foo` that declares `Foo::Set`) is written `::Set`.
1710
2204
  def elaborated_rbs(type, owner: nil)
1711
- alias_index.fold(TypeElaborator.elaborate(type, environment: @environment).erase_to_rbs, owner)
2205
+ folded = alias_index.fold(TypeElaborator.elaborate(type, environment: @environment).erase_to_rbs, owner)
2206
+ captured = owner_type_param_names(owner)
2207
+ TypeNameQualification.qualify(folded, owner) { |name| captured.include?(name) || declared_constant?(name) }
2208
+ end
2209
+
2210
+ # Issue #2436 — inside `class Array[unchecked out E]` a bare `E` names the type variable, not a class `::E`,
2211
+ # so a class whose spelling equals one of the owner's declared type-parameter names is anchored. The names
2212
+ # come as `"Array::E"`, the form {TypeNameQualification.qualify} asks about for the owner's own namespace;
2213
+ # a namespace the owner is nested in does not bind its parameters inside the owner's declaration.
2214
+ def owner_type_param_names(owner)
2215
+ loader = owner && @environment&.rbs_loader
2216
+ return Set.new if loader.nil?
2217
+
2218
+ @owner_type_param_names ||= {}
2219
+ @owner_type_param_names.fetch(owner) do
2220
+ @owner_type_param_names[owner] = loader.class_declaration_type_params(owner).to_set do |p|
2221
+ "#{owner}::#{p.name}"
2222
+ end
2223
+ end
2224
+ end
2225
+
2226
+ # Whether a type name written as `name` resolves to a constant the run can see: one the RBS environment
2227
+ # declares, or a class or module the project's source declares, whether or not `sig/` has caught up with it.
2228
+ def declared_constant?(name)
2229
+ class_known?(name) || Reflection.discovered_class?(name, scope: file_base_scope(@environment))
1712
2230
  end
1713
2231
 
1714
2232
  # Issue #1002 — the reverse of {#translate_method_type_return}'s `alias_expander:`. Folding happens here,
@@ -1800,12 +2318,17 @@ module Rigor
1800
2318
  # Per-file context the attr_* walker threads through its recursive descent. Keeps parameter lists in
1801
2319
  # check. `obs_ivar_map` carries the observation-derived fallback types built by
1802
2320
  # {#build_observed_ivar_map}; it is empty when sig-gen is invoked without `--params=observed`.
1803
- AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, keyword_init: true)
2321
+ # `definer_table` is {#definer_table}'s map, which drops an attribute another definer replaces. A context
2322
+ # with `definers` only records each attribute's slot and call node for that map, and builds nothing.
2323
+ AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, :definer_table, :definers,
2324
+ keyword_init: true)
1804
2325
  private_constant :AttrWalkContext
1805
2326
 
1806
- def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {})
2327
+ def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {}, definer_table = {})
2328
+ @lookup_ivars = Set.new
2329
+ collect_lookup_ivar_writes(root, scope_index)
1807
2330
  ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
1808
- obs_ivar_map: obs_ivar_map, out: [])
2331
+ obs_ivar_map: obs_ivar_map, out: [], definer_table: definer_table)
1809
2332
  walk_attr_calls(root, [], false, ctx)
1810
2333
  ctx.out
1811
2334
  end
@@ -1834,7 +2357,7 @@ module Rigor
1834
2357
  return
1835
2358
  end
1836
2359
  when Prism::CallNode
1837
- return if refine_block_call?(node)
2360
+ return if skipped_block_call?(node)
1838
2361
 
1839
2362
  collect_attr_call(node, prefix, in_singleton_class, ctx)
1840
2363
  end
@@ -1850,11 +2373,67 @@ module Rigor
1850
2373
  class_name = prefix.join("::")
1851
2374
  symbol_names = extract_symbol_arguments(call_node)
1852
2375
  return if symbol_names.empty?
2376
+ return record_attr_definers(call_node, class_name, symbol_names, ctx) if ctx.definers
1853
2377
 
1854
2378
  ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name, ctx.obs_ivar_map)
2379
+ offset = call_node.location.start_offset
1855
2380
  symbol_names.each do |attr_name|
1856
- ivar_type = ivar_lookup.call(attr_name)
1857
- ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
2381
+ ivar_type = with_lookup_nil(ivar_lookup.call(attr_name), class_name, attr_name)
2382
+ candidates = build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx)
2383
+ ctx.out.concat(candidates.select do |c|
2384
+ live_definer?(ctx.definer_table, [class_name, :instance, c.method_name], offset)
2385
+ end)
2386
+ end
2387
+ end
2388
+
2389
+ # #2197 — an attribute whose ivar an instance method assigns from an optimistic lookup (`@x = h[k]`) reads
2390
+ # `nil` on a miss, as such a return does ({Inference::DefReturnTyper.optimistic_value?}), so the written
2391
+ # reader keeps the `nil` the engine's nil-free reading of the lookup drops.
2392
+ def with_lookup_nil(type, class_name, attr_name)
2393
+ return type if type.nil? || !@lookup_ivars.include?([class_name, :"@#{attr_name}"])
2394
+
2395
+ Type::Combinator.union(type, Type::Combinator.constant_of(nil))
2396
+ end
2397
+
2398
+ LOOKUP_IVAR_WRITES = [
2399
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
2400
+ ].freeze
2401
+ private_constant :LOOKUP_IVAR_WRITES
2402
+
2403
+ # Records `[class_name, ivar]` for each ivar an instance `def` assigns an optimistic lookup to. The class
2404
+ # prefixes come from {#collect_other_ivar_writes}' walk, whose own result is not needed here.
2405
+ def collect_lookup_ivar_writes(root, scope_index)
2406
+ collect_other_ivar_writes(root, [], false, Hash.new { |h, k| h[k] = Set.new }) do |def_node, prefix|
2407
+ next if def_node.receiver || prefix.empty?
2408
+
2409
+ scan_lookup_ivar_writes(def_node.body, prefix, scope_index)
2410
+ end
2411
+ end
2412
+
2413
+ def scan_lookup_ivar_writes(node, prefix, scope_index)
2414
+ return unless node.is_a?(Prism::Node)
2415
+
2416
+ record_lookup_ivar_write(node, prefix, scope_index)
2417
+ node.rigor_each_child { |child| scan_lookup_ivar_writes(child, prefix, scope_index) }
2418
+ end
2419
+
2420
+ def record_lookup_ivar_write(node, prefix, scope_index)
2421
+ return unless LOOKUP_IVAR_WRITES.any? { |klass| node.is_a?(klass) }
2422
+
2423
+ scope = scope_index[node.value] || scope_index[node]
2424
+ return if scope.nil? || !Inference::DefReturnTyper.optimistic_value?(
2425
+ node.value, scope, Inference::DefReturnTyper::LookupContext.new(scope_index, {}.compare_by_identity)
2426
+ )
2427
+
2428
+ @lookup_ivars << [prefix.join("::"), node.name]
2429
+ end
2430
+
2431
+ def record_attr_definers(call_node, class_name, symbol_names, ctx)
2432
+ symbol_names.each do |attr_name|
2433
+ ATTR_KINDS.fetch(call_node.name).each do |variant|
2434
+ method_name = variant == :writer ? :"#{attr_name}=" : attr_name
2435
+ ctx.definers << [[class_name, :instance, method_name], call_node]
2436
+ end
1858
2437
  end
1859
2438
  end
1860
2439
 
@@ -1885,8 +2464,8 @@ module Rigor
1885
2464
  end
1886
2465
  end
1887
2466
 
1888
- # Build a { class_name => { attr_name_sym => Type } } map that records observation-derived types for
1889
- # ivars assigned directly from `def initialize` parameters. Only populated when `@observations` is
2467
+ # Records the observation-derived types of ivars assigned directly from `def initialize` parameters, keyed
2468
+ # by class name and then attribute name. Only populated when `@observations` is
1890
2469
  # non-empty (i.e. `--params=observed` was supplied). Matches the pattern `@ivar_name = param_name` where
1891
2470
  # `param_name` is a required / optional positional or keyword parameter of `initialize`.
1892
2471
  #
@@ -1919,20 +2498,25 @@ module Rigor
1919
2498
  # and every write inside an instance `def` or a block, an `instance_variable_set` on `self` included
1920
2499
  # ({#written_ivar}). `def self.x`, a `class << self` body and a bare class-body write set the class
1921
2500
  # object's ivars and do not count.
1922
- def collect_other_ivar_writes(node, prefix, in_block, other_writes)
2501
+ #
2502
+ # `on_def`, when given, is called with each instance `def` reached and its class prefix, so another
2503
+ # per-class reading of instance-method ivar writes shares this walk ({#collect_lookup_ivar_writes}).
2504
+ def collect_other_ivar_writes(node, prefix, in_block, other_writes, &on_def)
1923
2505
  return unless node.is_a?(Prism::Node)
1924
2506
 
1925
2507
  case node
1926
2508
  when Prism::ClassNode, Prism::ModuleNode
1927
2509
  child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path)
1928
- return collect_other_ivar_writes(node.body, child_prefix, false, other_writes) if child_prefix
2510
+ return collect_other_ivar_writes(node.body, child_prefix, false, other_writes, &on_def) if child_prefix
1929
2511
  when Prism::SingletonClassNode then return
1930
- when Prism::DefNode then return record_def_ivar_writes(node, prefix, other_writes)
2512
+ when Prism::DefNode
2513
+ on_def&.call(node, prefix)
2514
+ return record_def_ivar_writes(node, prefix, other_writes)
1931
2515
  when Prism::BlockNode, Prism::LambdaNode then in_block = true
1932
2516
  end
1933
2517
  record_class_level_write(node, prefix, in_block, other_writes) unless prefix.empty?
1934
2518
 
1935
- node.rigor_each_child { |child| collect_other_ivar_writes(child, prefix, in_block, other_writes) }
2519
+ node.rigor_each_child { |child| collect_other_ivar_writes(child, prefix, in_block, other_writes, &on_def) }
1936
2520
  end
1937
2521
 
1938
2522
  # The ivars an `attr_writer` / `attr_accessor` on `self` names, or the ivar a write in a block sets.
@@ -2014,7 +2598,7 @@ module Rigor
2014
2598
  node.rigor_each_child { |c| collect_init_ivar_obs(c, prefix, result) }
2015
2599
  end
2016
2600
 
2017
- # Derive { attr_name_sym => Type } for a single `def initialize` by matching `@ivar = param_name`
2601
+ # Derives the observation-derived attribute types of a single `def initialize`, matching `@ivar = param_name`
2018
2602
  # assignments against the available `[class_name, :initialize]` observations.
2019
2603
  def ivar_obs_from_initialize(class_name, def_node)
2020
2604
  obs_list = @observations[[class_name, :initialize]]
@@ -2029,8 +2613,8 @@ module Rigor
2029
2613
  build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
2030
2614
  end
2031
2615
 
2032
- # Map `{ ivar_name => param_name }` → `{ attr_name_sym => Type }` by looking up each param's observation
2033
- # types and unioning them.
2616
+ # Maps `{ ivar_name => param_name }` to attribute types by looking up each param's observation types and
2617
+ # unioning them.
2034
2618
  def build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
2035
2619
  ivar_to_param.filter_map do |ivar_name, param_name|
2036
2620
  types = collect_param_obs_types(obs_list, param_name, param_index[param_name])