rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -8,11 +8,13 @@ require_relative "../project_environment"
8
8
  require_relative "../scope"
9
9
  require_relative "../reflection"
10
10
  require_relative "../type"
11
+ require_relative "../inference/reflective_ivar_write"
11
12
  require_relative "../source/constant_path"
12
13
  require_relative "../source/literals"
13
14
  require_relative "../source/node_children"
14
15
  require_relative "../inference/def_return_typer"
15
16
  require_relative "../inference/scope_indexer"
17
+ require_relative "../inference/project_seed_tables"
16
18
  require_relative "../inference/module_function_state"
17
19
  require_relative "../inference/rbs_type_translator"
18
20
  require_relative "alias_index"
@@ -25,6 +27,7 @@ require_relative "inline_declarations"
25
27
  require_relative "layout_index"
26
28
  require_relative "method_candidate"
27
29
  require_relative "writer"
30
+ require_relative "../rooted_glob"
28
31
 
29
32
  module Rigor
30
33
  module SigGen
@@ -97,7 +100,8 @@ module Rigor
97
100
  # candidates from one file don't leak into another; initialising empty here gives downstream consumers
98
101
  # (`build_candidate`, `method_def_prefix`) a never-nil invariant without per-call-site defensive guards.
99
102
  @namespace_kinds = {}
100
- @module_function_methods = Set.new
103
+ @module_function_methods = Set.new.compare_by_identity
104
+ @uncertain_module_function_defs = Set.new.compare_by_identity
101
105
  @class_shells = Set.new
102
106
  @class_superclasses = {}
103
107
  @meta_layouts = {}
@@ -123,6 +127,7 @@ module Rigor
123
127
 
124
128
  def run
125
129
  resolved = resolve_paths(@paths)
130
+ @resolved_paths = resolved
126
131
  @environment = build_environment(resolved)
127
132
  candidates = resolved.flat_map { |path| analyse_file(path, @environment) }
128
133
  annotate_effects(
@@ -446,7 +451,7 @@ module Rigor
446
451
  def resolve_paths(args)
447
452
  args.flat_map do |arg|
448
453
  if File.directory?(arg)
449
- Dir.glob(File.join(arg, "**/*.rb"), sort: true)
454
+ RootedGlob.glob(arg, "**/*.rb")
450
455
  elsif File.file?(arg) && arg.end_with?(".rb")
451
456
  [arg]
452
457
  else
@@ -455,25 +460,41 @@ module Rigor
455
460
  end.uniq
456
461
  end
457
462
 
463
+ # #1154 — the project's discovery tables, read only by the declared-parameter ivar credit: it must see the ivar
464
+ # writers in other files (an ancestor, another object's `instance_variable_set`, a reopening) as `rigor check`
465
+ # does. Built once per run over the configured project paths as well as the files sig-gen reads, so a run over
466
+ # one file declines what a `check` of it declines; every other part of the generator keeps its per-file scope.
467
+ def ivar_writer_scope(base_scope)
468
+ @ivar_writer_tables ||= Inference::ProjectSeedTables.for_paths(
469
+ ((@resolved_paths || []) + resolve_paths(Array(@configuration.paths))).uniq { |path| File.expand_path(path) }
470
+ )
471
+ base_scope.with_discovery(base_scope.discovery.with(**@ivar_writer_tables))
472
+ end
473
+
474
+ # #1406 — sig-gen keeps the census's own answer: a declared ivar type read back would echo the declaration.
475
+ def index_file(root, base_scope)
476
+ Inference::ScopeIndexer.index(
477
+ root, default_scope: base_scope, ivar_writer_scope: ivar_writer_scope(base_scope), declared_ivar_types: false
478
+ )
479
+ end
480
+
458
481
  def analyse_file(path, environment)
459
482
  source = File.read(path)
460
483
  parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
461
484
  return [] if parse_result.errors.any?
462
485
 
463
- base_scope = Scope.empty(environment: environment)
464
- scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
486
+ scope_index = index_file(parse_result.value, Scope.empty(environment: environment))
465
487
 
466
- @namespace_kinds = {}
467
- @module_function_methods = Set.new
468
- @class_shells = Set.new
469
- @class_superclasses = {}
488
+ reset_file_maps
470
489
  @meta_layouts = collect_meta_layouts(scope_index)
471
490
  register_meta_classes
472
491
  defs = collect_method_definitions(parse_result.value, scope_index)
492
+ definers = definer_table(parse_result.value, defs)
493
+ defs = live_definitions(defs, definers)
473
494
  # Candidate construction freezes the per-file maps above (see {#build_candidate}), so every registration
474
495
  # pass has to be finished before the first `build_candidate` call. Meta members lead the RETURNED order —
475
496
  # 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)
497
+ meta_candidates = live_meta_candidates(path, scope_index, definers)
477
498
  candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
478
499
  # An analyzer bug typing one def's body must cost only that def's candidate, never the whole
479
500
  # `rigor sig-gen` run. The `check` path recovers each *file* this way (worker_session.rb); sig-gen
@@ -485,7 +506,111 @@ module Rigor
485
506
  end
486
507
  obs_ivar_map = build_observed_ivar_map(parse_result.value)
487
508
  meta_candidates + candidates_from_defs +
488
- collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
509
+ collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map, definers)
510
+ end
511
+
512
+ # Ruby keeps the LAST definition of a name: a `def x` followed by `attr_reader :x` (or the reverse) defines
513
+ # `x` once, and a second `def x` after a `module_function` reset replaces only the instance method. Writing
514
+ # one entry per definition gave RBS a duplicate method definition (#2008, #2023), so every definer of a
515
+ # `(class, kind, name)` slot is recorded with its source offset, and only the last one in a file is written.
516
+ # A module function defines two slots, its singleton copy and its instance method. A `Data.define` or
517
+ # `Struct.new` member is defined before any body that could redefine it, so it ranks first.
518
+ #
519
+ # Only a definer whose statement certainly runs can be last: a direct statement of a `class`, `module` or
520
+ # `class << self` body, of a `Data.define` / `Struct.new` block or of the file ({#walk_statements} records
521
+ # them). One under a condition, in a block or a lambda, passed to a call (`private def x`), or after an
522
+ # uncertain `module_function` reset may not run, as far as this reading tells. A slot such a definer could
523
+ # still take over from the last certain one is contested, and nothing is written for it.
524
+ #
525
+ # @return `{ [class_name, kind, method_name] => [[offset, certain], ...] }`.
526
+ MEMBER_OFFSET = -1
527
+ private_constant :MEMBER_OFFSET
528
+
529
+ def definer_table(root, defs)
530
+ certain = @certain_definers
531
+ table = Hash.new { |h, k| h[k] = [] }
532
+ record_def_definers(defs, certain, table)
533
+ meta_member_slots.each { |slot| table[slot] << [MEMBER_OFFSET, true] }
534
+ ctx = AttrWalkContext.new(definers: [])
535
+ walk_attr_calls(root, [], false, ctx)
536
+ ctx.definers.each do |slot, call_node|
537
+ table[slot] << [call_node.location.start_offset, certain.include?(call_node)]
538
+ end
539
+ table
540
+ end
541
+
542
+ def record_def_definers(defs, certain, table)
543
+ defs.each do |def_node, class_name, kind|
544
+ next if class_name.nil?
545
+
546
+ definer_slots(def_node, class_name, kind).each do |slot, possible_only|
547
+ table[slot] << [def_node.location.start_offset, !possible_only && certain.include?(def_node)]
548
+ end
549
+ end
550
+ end
551
+
552
+ def live_meta_candidates(path, scope_index, table)
553
+ collect_meta_member_candidates(path, scope_index).select do |c|
554
+ live_definer?(table, [c.class_name, c.kind, c.method_name], MEMBER_OFFSET)
555
+ end
556
+ end
557
+
558
+ # `[slot, possible_only]` pairs. A `def` after an uncertain `module_function` reset may or may not install a
559
+ # singleton copy, so it is a possible definer of that slot only.
560
+ def definer_slots(def_node, class_name, kind)
561
+ slots = [[[class_name, kind, def_node.name], false]]
562
+ if @module_function_methods.include?(def_node)
563
+ slots << [[class_name, :singleton, def_node.name], false]
564
+ elsif @uncertain_module_function_defs.include?(def_node)
565
+ slots << [[class_name, :singleton, def_node.name], true]
566
+ end
567
+ slots
568
+ end
569
+
570
+ def meta_member_slots
571
+ @meta_layouts.flat_map do |class_name, layout|
572
+ MetaClassShape.of(kind: layout.kind, members: layout.member_names, keyword_init: layout.keyword_init)
573
+ .member_decls.map { |member| [class_name, member.kind, member.method_name] }
574
+ end
575
+ end
576
+
577
+ # Whether the definer at `offset` is the one Ruby keeps for `slot`. A slot with a single definer keeps it,
578
+ # certain or not, as before.
579
+ def live_definer?(table, slot, offset)
580
+ entries = table.fetch(slot, [])
581
+ return true if entries.size <= 1
582
+
583
+ last_certain = entries.select { |_, certain| certain }.map(&:first).max
584
+ return false if last_certain.nil?
585
+ return false if entries.any? { |entry_offset, certain| !certain && entry_offset > last_certain }
586
+
587
+ offset == last_certain
588
+ end
589
+
590
+ # The `defs` whose definition Ruby keeps. A module function whose instance method a later definition
591
+ # replaced keeps only its singleton copy (`M.label` still returns the first body), so it is written as
592
+ # `def self.label`. One whose singleton copy was replaced keeps only a private instance method, which
593
+ # sig-gen does not write, so it is dropped.
594
+ def live_definitions(defs, table)
595
+ defs.filter_map do |def_node, class_name, kind|
596
+ next [def_node, class_name, kind] if class_name.nil?
597
+
598
+ slots = definer_slots(def_node, class_name, kind).reject(&:last).map(&:first)
599
+ live = slots.select { |slot| live_definer?(table, slot, def_node.location.start_offset) }
600
+ next [def_node, class_name, kind] if live.size == slots.size
601
+ next nil unless live == [[class_name, :singleton, def_node.name]]
602
+
603
+ [def_node, class_name, :singleton]
604
+ end
605
+ end
606
+
607
+ # The per-file scratch state {#analyse_file} starts each file with.
608
+ def reset_file_maps
609
+ @namespace_kinds = {}
610
+ @module_function_methods = Set.new.compare_by_identity
611
+ @uncertain_module_function_defs = Set.new.compare_by_identity
612
+ @class_shells = Set.new
613
+ @class_superclasses = {}
489
614
  end
490
615
 
491
616
  # Walks the AST collecting `(def_node, class_name, kind)` tuples for every `def` Rigor can re-type. Slice 1
@@ -500,15 +625,19 @@ module Rigor
500
625
  # - `@namespace_kinds[qualified_name]` records whether each segment came from `class Foo` (`:class`) or
501
626
  # `module Foo` (`:module`). Used by the writer's `wrap_in_modules` step to emit the right keyword for
502
627
  # each intermediate segment AND the leaf.
503
- # - `@module_function_methods` records `(class_name, method_name)` pairs where a `module_function` (no
504
- # args) call preceded the `def` inside a module body. The renderer emits `def self?.name` for these, the
505
- # RBS spelling that matches the dual instance + singleton dispatch the runtime produces.
628
+ # - `@module_function_methods` records the `def` nodes a `module_function` (no args) call preceded inside
629
+ # a module body. The renderer emits `def self?.name` for these, the RBS spelling that matches the dual
630
+ # instance + singleton dispatch the runtime produces. It is keyed by node, not by name: a later `def` of
631
+ # the same name after a reset is an ordinary method (#2023).
506
632
  def collect_method_definitions(root, scope_index)
507
633
  @declared_types = scope_index[root].declared_types
508
634
  @walk_root = root
509
635
  @in_effect_refinements = nil
510
636
  out = []
511
637
  @block_depth = 0
638
+ @certain_bodies = Set.new.compare_by_identity
639
+ @certain_definers = Set.new.compare_by_identity
640
+ mark_certain_body(root.statements)
512
641
  walk_defs(root, [], false, false, out)
513
642
  out
514
643
  end
@@ -521,6 +650,7 @@ module Rigor
521
650
  return if descend_into_namespace?(node, prefix, out)
522
651
  when Prism::SingletonClassNode
523
652
  if node.expression.is_a?(Prism::SelfNode) && node.body
653
+ mark_certain_body(node.body)
524
654
  walk_defs(node.body, prefix, true, false, out)
525
655
  return
526
656
  end
@@ -530,6 +660,7 @@ module Rigor
530
660
  when Prism::ConstantWriteNode
531
661
  body = meta_block_body(node, prefix)
532
662
  if body
663
+ mark_certain_body(body)
533
664
  walk_defs(body, prefix + [node.name.to_s], false, false, out)
534
665
  return
535
666
  end
@@ -708,13 +839,30 @@ module Rigor
708
839
  def walk_namespace_body(namespace_node, prefix, out)
709
840
  return if namespace_node.body.nil?
710
841
 
842
+ mark_certain_body(namespace_node.body)
711
843
  walk_defs(namespace_node.body, prefix, false, false, out)
712
844
  end
713
845
 
846
+ # A body whose direct statements certainly run when it does ({#definer_table}): its statement list unless the
847
+ # body has a `rescue` clause, and its `ensure` clause. A `rescue` or `else` clause may not run.
848
+ def mark_certain_body(body)
849
+ return @certain_bodies << body unless body.is_a?(Prism::BeginNode)
850
+
851
+ # With a `rescue`, a statement before a definer can raise and skip it, so the main list is not certain.
852
+ @certain_bodies << body.statements if body.statements && body.rescue_clause.nil?
853
+ @certain_bodies << body.ensure_clause.statements if body.ensure_clause&.statements
854
+ end
855
+
714
856
  # The directive's reach is {Inference::ModuleFunctionState.each_sig_gen_statement}.
715
857
  def walk_statements(stmts_node, prefix, in_singleton_class, module_function_active, out)
858
+ certain_body = @certain_bodies.include?(stmts_node)
716
859
  Inference::ModuleFunctionState.each_sig_gen_statement(stmts_node.body,
717
860
  module_function_active) do |stmt, active|
861
+ if certain_body
862
+ @certain_definers << stmt
863
+ # A `begin … end` statement's body (and `ensure`) runs whenever the statement does.
864
+ mark_certain_body(stmt) if stmt.is_a?(Prism::BeginNode)
865
+ end
718
866
  walk_defs(stmt, prefix, in_singleton_class, active, out)
719
867
  end
720
868
  end
@@ -731,7 +879,9 @@ module Rigor
731
879
  kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
732
880
  class_name = prefix.join("::")
733
881
  if Inference::ModuleFunctionState.sig_gen_module_function?(module_function_active, kind)
734
- @module_function_methods << [class_name, node.name]
882
+ @module_function_methods << node
883
+ elsif Inference::ModuleFunctionState.sig_gen_uncertain?(module_function_active, kind)
884
+ @uncertain_module_function_defs << node
735
885
  end
736
886
  out << [node, class_name, kind]
737
887
  end
@@ -771,9 +921,9 @@ module Rigor
771
921
 
772
922
  # Returns "def self." (kind: :singleton), "def self?." (instance method declared inside a
773
923
  # `module_function` region — both instance + singleton dispatch at runtime), or "def " (plain instance).
774
- def method_def_prefix(class_name, method_name, kind)
924
+ def method_def_prefix(def_node, kind)
775
925
  return "def self." if kind == :singleton
776
- return "def self?." if @module_function_methods.include?([class_name, method_name])
926
+ return "def self?." if @module_function_methods.include?(def_node)
777
927
 
778
928
  "def "
779
929
  end
@@ -783,9 +933,13 @@ module Rigor
783
933
  # private methods are implementation details, not part of the type contract downstream consumers (Steep,
784
934
  # IDE, gem users) read. The default now skips private and protected methods; the `:include_private` flag
785
935
  # restores the slice-4 behaviour for callers that want every method.
936
+ #
937
+ # A module function renders as `def self?.name`, which RBS reads as a public singleton method and a private
938
+ # instance method, so a `private` written before the `module_function` toggle does not exclude it (#1968).
786
939
  def visibility_excludes?(def_node, class_name, kind, scope_index)
787
940
  return false if kind == :singleton
788
941
  return false if @include_private
942
+ return false if @module_function_methods.include?(def_node)
789
943
 
790
944
  scope = scope_index[def_node] || scope_index.each_value.first
791
945
  return false if scope.nil?
@@ -960,6 +1114,9 @@ module Rigor
960
1114
  # #1676 — a top-level `def` is collected with no class; it is reported, never typed.
961
1115
  def classify_collected_def(path, def_node, class_name, kind, scope_index)
962
1116
  return skipped(path, def_node, class_name, kind, :top_level_def) if class_name.nil?
1117
+ if @uncertain_module_function_defs.include?(def_node)
1118
+ return skipped(path, def_node, class_name, kind, :uncertain_module_function)
1119
+ end
963
1120
 
964
1121
  classify_def(path, def_node, class_name, kind, scope_index)
965
1122
  end
@@ -1020,7 +1177,7 @@ module Rigor
1020
1177
 
1021
1178
  scope = scope_index[def_node]
1022
1179
  method_def = lookup_existing_method(class_name, def_node.name, kind, scope&.environment, scope)
1023
- head = "#{method_def_prefix(class_name, def_node.name, kind)}#{def_node.name}: "
1180
+ head = "#{method_def_prefix(def_node, kind)}#{def_node.name}: "
1024
1181
  if return_defaulted
1025
1182
  return mixed_provenance_candidate(path, def_node, class_name, kind, scope_index, inline, method_def, head)
1026
1183
  end
@@ -1240,7 +1397,37 @@ module Rigor
1240
1397
  # different method (issue #1382). Delegates to {Rigor::Inference::DefReturnTyper} — the same body-typing +
1241
1398
  # explicit-return-union the `rigor annotate` def-line annotator uses.
1242
1399
  def infer_return_type(def_node, scope_index)
1243
- Inference::DefReturnTyper.call(def_node, scope_index)
1400
+ absorb_empty_literals(Inference::DefReturnTyper.call(def_node, scope_index, keep_lookup_nil: true))
1401
+ end
1402
+
1403
+ # Issue #1153 — an empty-collection early return (`return [] if table.empty?`) types its arm `[]`, which the
1404
+ # engine's union keeps beside a sibling `Array[E]` arm on purpose (the empty tuple is a distinct shape). A
1405
+ # signature has no use for the distinction: `[]` is an `Array[E]` for every `E`, so `Array[E] | []` is just
1406
+ # `Array[E]`, and the leftover `[]` arm made the proposal read as if the empty literal pinned the element.
1407
+ # Issue #1149 — the same holds for the empty closed hash shape `{}` (a frozen empty fallback,
1408
+ # `table[key] || EMPTY`) beside a `Hash[K, V]` nominal. Each empty literal is absorbed only beside its OWN
1409
+ # nominal: a `[]` beside a `Hash` arm, or a `{}` beside an `Array` arm, is untouched. A lone empty literal,
1410
+ # one beside another tuple / shape (`[1, 2] | []`), an open `{ ... }` shape, and a non-empty shape are
1411
+ # untouched too, since each of those is information the declaration should keep.
1412
+ def absorb_empty_literals(type)
1413
+ return type unless type.is_a?(Type::Union)
1414
+
1415
+ kept = type.members.reject { |member| absorbed_empty_literal?(member, type.members) }
1416
+ kept.size == type.members.size ? type : Type::Combinator.union(*kept)
1417
+ end
1418
+
1419
+ def absorbed_empty_literal?(member, members)
1420
+ if member.is_a?(Type::Tuple) && member.elements.empty?
1421
+ nominal_arm?(members, "Array")
1422
+ elsif member.is_a?(Type::HashShape) && member.pairs.empty? && member.closed?
1423
+ nominal_arm?(members, "Hash")
1424
+ else
1425
+ false
1426
+ end
1427
+ end
1428
+
1429
+ def nominal_arm?(members, class_name)
1430
+ members.any? { |member| member.is_a?(Type::Nominal) && member.class_name == class_name }
1244
1431
  end
1245
1432
 
1246
1433
  def dynamic_top?(type)
@@ -1287,7 +1474,7 @@ module Rigor
1287
1474
  return equivalent(path, def_node, class_name, kind, inferred, VOID_RETURN_RBS) if declares_void?(method_def)
1288
1475
 
1289
1476
  declared = build_declared_return(method_def)
1290
- if declared_untyped?(declared)
1477
+ if declared_untyped?(declared, method_def)
1291
1478
  return declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: rendered)
1292
1479
  end
1293
1480
 
@@ -1354,7 +1541,7 @@ module Rigor
1354
1541
  returned = paren_wrap_union(elaborated_rbs(inferred, owner: class_name))
1355
1542
  annotations = overload.annotations.map { |a| "#{a.location&.source || "%a{#{a.string}}"} " }.join
1356
1543
  visibility = member.respond_to?(:visibility) && member.visibility ? "#{member.visibility} " : ""
1357
- "#{visibility}#{method_def_prefix(class_name, def_node.name, kind)}#{def_node.name}: " \
1544
+ "#{visibility}#{method_def_prefix(def_node, kind)}#{def_node.name}: " \
1358
1545
  "#{annotations}#{spell_with_return(overload.method_type, returned)}"
1359
1546
  end
1360
1547
 
@@ -1413,20 +1600,30 @@ module Rigor
1413
1600
  # skips. ADR-93's inline `# @rbs param: Type` annotation (no return clause) synthesizes exactly this
1414
1601
  # spelling for every method it documents, so the silent drop hit the parameter-annotated, return-bare
1415
1602
  # shape ADR-14 exists to help.
1416
- def declared_untyped?(declared)
1417
- declared.is_a?(Type::Dynamic)
1603
+ #
1604
+ # The translation alone cannot say the author wrote `untyped`: an interface return (`-> _Taker[1]`,
1605
+ # `-> _ToS`) also translates to `Dynamic`, and reading it as an absent declaration replaced it with an
1606
+ # unrelated nominal under `--overwrite` (#2178). So every overload's return must be `untyped` as written.
1607
+ def declared_untyped?(declared, method_def)
1608
+ declared.is_a?(Type::Dynamic) &&
1609
+ method_def.method_types.all? { |mt| mt.type.return_type.is_a?(RBS::Types::Bases::Any) }
1418
1610
  end
1419
1611
 
1420
1612
  # Proposed without {#tighter?}: a declared `untyped` return carries no information to weigh it against, the
1421
1613
  # same position a method with no declaration at all is in. Over a declaration the author wrote, though, a
1422
1614
  # VALUE literal the body proves (`-> 1`, `-> "x"`, `-> :k`) is an implementation detail the `untyped` was
1423
1615
  # abstracting over, so it is widened to its class (`1 | 2` to `Integer`, `"S" | nil` to `(String | nil)`; #1489).
1424
- # `nil`, `true` and `false` are honest types and stay, as does a computed `Float`. An `untyped` that is only
1425
- # the inline reader's synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the
1426
- # strictest carrier. `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag
1427
- # says a declaration existed.
1616
+ # `true` and `false` widen to `bool` (#1891): a predicate's base `-> true` made every subclass override
1617
+ # returning `false` a `def.override-return-widened`. A return that is only `nil` has no such widening — it is
1618
+ # the shape of a template method a subclass fills in — so it is declined and the `untyped` stays. `nil` in a
1619
+ # union (`T?`) and a computed `Float` are honest types and stay. An `untyped` that is only the inline reader's
1620
+ # synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the strictest carrier.
1621
+ # `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag says a declaration
1622
+ # existed.
1428
1623
  def declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: nil)
1429
1624
  unless synthesized_declaration?(method_def)
1625
+ return equivalent(path, def_node, class_name, kind, inferred, "untyped") if bare_nil?(inferred)
1626
+
1430
1627
  widened = widen_value_literals(inferred)
1431
1628
  unless widened.equal?(inferred)
1432
1629
  # A `rendered` line was spelled from the unwidened type; there is nothing to rebuild it from.
@@ -1442,21 +1639,60 @@ module Rigor
1442
1639
  VALUE_LITERAL_CLASSES = [Integer, String, Symbol].freeze
1443
1640
  private_constant :VALUE_LITERAL_CLASSES
1444
1641
 
1445
- # The same object back when nothing needed widening.
1642
+ # Core generics whose RBS parameters are all `out` or `unchecked out`, so a widened argument is a supertype of
1643
+ # the body's type. An invariant or `in` parameter (a user `class Sink[in T]`) is not widened: `Sink[Integer]`
1644
+ # is no supertype of `Sink[1]` there.
1645
+ COVARIANT_CARRIERS = %w[Array Hash Set Range Enumerator].freeze
1646
+ private_constant :COVARIANT_CARRIERS
1647
+
1648
+ # The same object back when nothing needed widening. A literal nested in a tuple element, a record or hash
1649
+ # value, or a covariant generic argument (`[1, "a"]`, `{ a: 1 }`, `Hash[String, 1]`) widens the same way, while
1650
+ # the carrier's shape and a record's keys stay: a subclass override returning `[2, "b"]` is no widening of
1651
+ # `[Integer, String]`. A nested `nil` is an honest member there, as in a top-level union.
1446
1652
  def widen_value_literals(type)
1447
1653
  case type
1448
1654
  when Type::Constant
1655
+ return widen_boolean_literal if [true, false].include?(type.value)
1449
1656
  return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
1450
1657
 
1451
1658
  Type::Combinator.nominal_of(type.value.class.name)
1452
- when Type::Union
1453
- members = type.members.map { |member| widen_value_literals(member) }
1454
- members.each_with_index.all? { |m, i| m.equal?(type.members[i]) } ? type : Type::Combinator.union(*members)
1659
+ when Type::Union then widen_members(type.members, type) { |members| Type::Combinator.union(*members) }
1660
+ when Type::Tuple then widen_members(type.elements, type) { |elements| Type::Combinator.tuple_of(*elements) }
1661
+ when Type::HashShape then widen_hash_shape_values(type)
1662
+ when Type::Nominal
1663
+ return type unless COVARIANT_CARRIERS.include?(type.class_name)
1664
+
1665
+ widen_members(type.type_args, type) do |args|
1666
+ Type::Combinator.nominal_of(type.class_name, type_args: args)
1667
+ end
1455
1668
  else
1456
1669
  type
1457
1670
  end
1458
1671
  end
1459
1672
 
1673
+ def widen_members(members, type)
1674
+ widened = members.map { |member| widen_value_literals(member) }
1675
+ widened.each_with_index.all? { |m, i| m.equal?(members[i]) } ? type : yield(widened)
1676
+ end
1677
+
1678
+ def widen_hash_shape_values(shape)
1679
+ widen_members(shape.pairs.values, shape) do |values|
1680
+ Type::HashShape.new(
1681
+ shape.pairs.keys.zip(values).to_h,
1682
+ required_keys: shape.required_keys, optional_keys: shape.optional_keys,
1683
+ read_only_keys: shape.read_only_keys, extra_keys: shape.extra_keys
1684
+ )
1685
+ end
1686
+ end
1687
+
1688
+ def widen_boolean_literal
1689
+ Type::Combinator.union(Type::Combinator.constant_of(true), Type::Combinator.constant_of(false))
1690
+ end
1691
+
1692
+ def bare_nil?(type)
1693
+ type.is_a?(Type::Constant) && type.value.nil?
1694
+ end
1695
+
1460
1696
  # A declaration only the inline reader's synthesis (a `virtual:` buffer) supplies — ADR-93 gives every `def` an
1461
1697
  # `untyped` member, annotated or not — is no word of the author's about the return, so it keeps the proposal
1462
1698
  # a method with no declaration at all gets: the strictest carrier the body proves.
@@ -1590,11 +1826,14 @@ module Rigor
1590
1826
  end
1591
1827
 
1592
1828
  # `Type::Constant#erase_to_rbs` spells the values RBS writes as literal types (`true` / `false` /
1593
- # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as its value's class
1594
- # name. Asking which of the two branches it took keeps the list of literal-spellable classes from
1595
- # forking a second copy into this file.
1829
+ # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as a nominal. Asking
1830
+ # which of the two branches it took keeps the list of literal-spellable classes from forking a second
1831
+ # copy into this file. A Range's and a Set's nominal carries a type argument (`Range[Integer]`, #1891;
1832
+ # `Set[untyped]`, #2177), so they are excluded by class rather than by comparing the erasure with the class
1833
+ # name: like a Float each is a nominal, not a pinned literal.
1596
1834
  def rbs_literal?(type)
1597
- type.is_a?(Type::Constant) && type.erase_to_rbs != type.value.class.name
1835
+ type.is_a?(Type::Constant) && !type.value.is_a?(Range) && !type.value.is_a?(::Set) &&
1836
+ type.erase_to_rbs != type.value.class.name
1598
1837
  end
1599
1838
 
1600
1839
  # Heuristic added after the third-round self-dogfood: `FallbackTracer#size` body is `@events.size`, where
@@ -1662,7 +1901,7 @@ module Rigor
1662
1901
  def render_rbs_line(def_node, inferred, class_name, kind)
1663
1902
  params = def_node.parameters
1664
1903
  head = "(#{render_param_list(params, class_name, def_node.name)})#{block_signature_suffix(params)}"
1665
- prefix = method_def_prefix(class_name, def_node.name, kind)
1904
+ prefix = method_def_prefix(def_node, kind)
1666
1905
  "#{prefix}#{def_node.name}: #{head} -> #{paren_wrap_union(elaborated_rbs(inferred, owner: class_name))}"
1667
1906
  end
1668
1907
 
@@ -1766,12 +2005,17 @@ module Rigor
1766
2005
  # Per-file context the attr_* walker threads through its recursive descent. Keeps parameter lists in
1767
2006
  # check. `obs_ivar_map` carries the observation-derived fallback types built by
1768
2007
  # {#build_observed_ivar_map}; it is empty when sig-gen is invoked without `--params=observed`.
1769
- AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, keyword_init: true)
2008
+ # `definer_table` is {#definer_table}'s map, which drops an attribute another definer replaces. A context
2009
+ # with `definers` only records each attribute's slot and call node for that map, and builds nothing.
2010
+ AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, :definer_table, :definers,
2011
+ keyword_init: true)
1770
2012
  private_constant :AttrWalkContext
1771
2013
 
1772
- def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {})
2014
+ def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {}, definer_table = {})
2015
+ @lookup_ivars = Set.new
2016
+ collect_lookup_ivar_writes(root, scope_index)
1773
2017
  ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
1774
- obs_ivar_map: obs_ivar_map, out: [])
2018
+ obs_ivar_map: obs_ivar_map, out: [], definer_table: definer_table)
1775
2019
  walk_attr_calls(root, [], false, ctx)
1776
2020
  ctx.out
1777
2021
  end
@@ -1816,11 +2060,67 @@ module Rigor
1816
2060
  class_name = prefix.join("::")
1817
2061
  symbol_names = extract_symbol_arguments(call_node)
1818
2062
  return if symbol_names.empty?
2063
+ return record_attr_definers(call_node, class_name, symbol_names, ctx) if ctx.definers
1819
2064
 
1820
2065
  ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name, ctx.obs_ivar_map)
2066
+ offset = call_node.location.start_offset
2067
+ symbol_names.each do |attr_name|
2068
+ ivar_type = with_lookup_nil(ivar_lookup.call(attr_name), class_name, attr_name)
2069
+ candidates = build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx)
2070
+ ctx.out.concat(candidates.select do |c|
2071
+ live_definer?(ctx.definer_table, [class_name, :instance, c.method_name], offset)
2072
+ end)
2073
+ end
2074
+ end
2075
+
2076
+ # #2197 — an attribute whose ivar an instance method assigns from an optimistic lookup (`@x = h[k]`) reads
2077
+ # `nil` on a miss, as such a return does ({Inference::DefReturnTyper.optimistic_value?}), so the written
2078
+ # reader keeps the `nil` the engine's nil-free reading of the lookup drops.
2079
+ def with_lookup_nil(type, class_name, attr_name)
2080
+ return type if type.nil? || !@lookup_ivars.include?([class_name, :"@#{attr_name}"])
2081
+
2082
+ Type::Combinator.union(type, Type::Combinator.constant_of(nil))
2083
+ end
2084
+
2085
+ LOOKUP_IVAR_WRITES = [
2086
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
2087
+ ].freeze
2088
+ private_constant :LOOKUP_IVAR_WRITES
2089
+
2090
+ # Records `[class_name, ivar]` for each ivar an instance `def` assigns an optimistic lookup to. The class
2091
+ # prefixes come from {#collect_other_ivar_writes}' walk, whose own result is not needed here.
2092
+ def collect_lookup_ivar_writes(root, scope_index)
2093
+ collect_other_ivar_writes(root, [], false, Hash.new { |h, k| h[k] = Set.new }) do |def_node, prefix|
2094
+ next if def_node.receiver || prefix.empty?
2095
+
2096
+ scan_lookup_ivar_writes(def_node.body, prefix, scope_index)
2097
+ end
2098
+ end
2099
+
2100
+ def scan_lookup_ivar_writes(node, prefix, scope_index)
2101
+ return unless node.is_a?(Prism::Node)
2102
+
2103
+ record_lookup_ivar_write(node, prefix, scope_index)
2104
+ node.rigor_each_child { |child| scan_lookup_ivar_writes(child, prefix, scope_index) }
2105
+ end
2106
+
2107
+ def record_lookup_ivar_write(node, prefix, scope_index)
2108
+ return unless LOOKUP_IVAR_WRITES.any? { |klass| node.is_a?(klass) }
2109
+
2110
+ scope = scope_index[node.value] || scope_index[node]
2111
+ return if scope.nil? || !Inference::DefReturnTyper.optimistic_value?(
2112
+ node.value, scope, Inference::DefReturnTyper::LookupContext.new(scope_index, {}.compare_by_identity)
2113
+ )
2114
+
2115
+ @lookup_ivars << [prefix.join("::"), node.name]
2116
+ end
2117
+
2118
+ def record_attr_definers(call_node, class_name, symbol_names, ctx)
1821
2119
  symbol_names.each do |attr_name|
1822
- ivar_type = ivar_lookup.call(attr_name)
1823
- ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
2120
+ ATTR_KINDS.fetch(call_node.name).each do |variant|
2121
+ method_name = variant == :writer ? :"#{attr_name}=" : attr_name
2122
+ ctx.definers << [[class_name, :instance, method_name], call_node]
2123
+ end
1824
2124
  end
1825
2125
  end
1826
2126
 
@@ -1837,7 +2137,8 @@ module Rigor
1837
2137
  # pre-pass resolved the type to `nil` or `Dynamic[top]` — typically because `@ivar = param` inside
1838
2138
  # `initialize` typed the param as `untyped` — the observation-derived type is substituted. This lets
1839
2139
  # `attr_reader :name` emit a concrete type when `ClassName.new("alice")` call sites are visible to the
1840
- # observation scan.
2140
+ # observation scan. {#build_observed_ivar_map} leaves out an ivar anything else writes, so a writer-backed
2141
+ # field is skipped (issue #1410).
1841
2142
  def ivar_type_lookup(scope_index, class_name, obs_ivar_map = {})
1842
2143
  any_scope = scope_index.each_value.first
1843
2144
  return ->(_) {} if any_scope.nil?
@@ -1854,12 +2155,112 @@ module Rigor
1854
2155
  # ivars assigned directly from `def initialize` parameters. Only populated when `@observations` is
1855
2156
  # non-empty (i.e. `--params=observed` was supplied). Matches the pattern `@ivar_name = param_name` where
1856
2157
  # `param_name` is a required / optional positional or keyword parameter of `initialize`.
2158
+ #
2159
+ # Issue #1410: the observed arguments describe the field only while those `@ivar = param_name` writes are
2160
+ # all that writes it. An `attr_writer` / `attr_accessor`, a write in another instance method or block, or
2161
+ # any other write in `initialize` lets the field hold values the observation never saw, so such an ivar is
2162
+ # left out and its accessor is skipped as `untyped_return`, as a hand-written setter is.
1857
2163
  def build_observed_ivar_map(root)
1858
2164
  return {} if @observations.empty?
1859
2165
 
1860
2166
  result = {}
1861
2167
  collect_init_ivar_obs(root, [], result)
1862
- result
2168
+ other_writes = Hash.new { |h, k| h[k] = Set.new }
2169
+ collect_other_ivar_writes(root, [], false, other_writes)
2170
+ result.to_h do |class_name, map|
2171
+ others = other_writes[class_name]
2172
+ [class_name, others.include?(ANY_IVAR) ? {} : map.reject { |attr_name, _| others.include?(:"@#{attr_name}") }]
2173
+ end
2174
+ end
2175
+
2176
+ IVAR_WRITE_NODES = [
2177
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
2178
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
2179
+ ].freeze
2180
+ ANY_IVAR = :*
2181
+ private_constant :IVAR_WRITE_NODES, :ANY_IVAR
2182
+
2183
+ # Records into `other_writes[class_name]` every instance ivar a class writes other than through an
2184
+ # `initialize`'s `@ivar = param_name`: the ivars an implicit-self `attr_writer` / `attr_accessor` names,
2185
+ # and every write inside an instance `def` or a block, an `instance_variable_set` on `self` included
2186
+ # ({#written_ivar}). `def self.x`, a `class << self` body and a bare class-body write set the class
2187
+ # object's ivars and do not count.
2188
+ #
2189
+ # `on_def`, when given, is called with each instance `def` reached and its class prefix, so another
2190
+ # per-class reading of instance-method ivar writes shares this walk ({#collect_lookup_ivar_writes}).
2191
+ def collect_other_ivar_writes(node, prefix, in_block, other_writes, &on_def)
2192
+ return unless node.is_a?(Prism::Node)
2193
+
2194
+ case node
2195
+ when Prism::ClassNode, Prism::ModuleNode
2196
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path)
2197
+ return collect_other_ivar_writes(node.body, child_prefix, false, other_writes, &on_def) if child_prefix
2198
+ when Prism::SingletonClassNode then return
2199
+ when Prism::DefNode
2200
+ on_def&.call(node, prefix)
2201
+ return record_def_ivar_writes(node, prefix, other_writes)
2202
+ when Prism::BlockNode, Prism::LambdaNode then in_block = true
2203
+ end
2204
+ record_class_level_write(node, prefix, in_block, other_writes) unless prefix.empty?
2205
+
2206
+ node.rigor_each_child { |child| collect_other_ivar_writes(child, prefix, in_block, other_writes, &on_def) }
2207
+ end
2208
+
2209
+ # The ivars an `attr_writer` / `attr_accessor` on `self` names, or the ivar a write in a block sets.
2210
+ def record_class_level_write(node, prefix, in_block, other_writes)
2211
+ writes = other_writes[prefix.join("::")]
2212
+ attr_writer_names(node).each { |name| writes << :"@#{name}" }
2213
+ name = written_ivar(node) if in_block
2214
+ writes << name if name
2215
+ end
2216
+
2217
+ ATTR_WRITER_DECLARATIONS = %i[attr_writer attr_accessor].freeze
2218
+ SEND_NAMES = %i[send public_send __send__].freeze
2219
+ private_constant :ATTR_WRITER_DECLARATIONS, :SEND_NAMES
2220
+
2221
+ # The literal names `attr_writer :x` / `attr_accessor :x` declares when called on `self`, implicitly or as
2222
+ # `self.`, or through `send` / `public_send` / `__send__` with the declaration's name as a literal first
2223
+ # argument (`send(:attr_writer, :x)`). Any other call declares none this check sees.
2224
+ def attr_writer_names(node)
2225
+ return [] unless node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
2226
+
2227
+ names = Source::Literals.symbol_arguments(node)
2228
+ return names if ATTR_WRITER_DECLARATIONS.include?(node.name)
2229
+ return [] unless SEND_NAMES.include?(node.name)
2230
+
2231
+ ATTR_WRITER_DECLARATIONS.include?(Source::Literals.symbol_arg(node, 0)) ? names.drop(1) : []
2232
+ end
2233
+
2234
+ # Every ivar write in an instance `def` counts, except, in `initialize`, the `@ivar = param_name` write
2235
+ # {#scan_ivar_param_assignments} keys the observation on.
2236
+ def record_def_ivar_writes(def_node, prefix, other_writes)
2237
+ return if def_node.body.nil? || def_node.receiver || prefix.empty?
2238
+
2239
+ credited = Set.new.compare_by_identity
2240
+ if def_node.name == :initialize && def_node.parameters
2241
+ params = build_init_param_index(def_node.parameters).keys.to_set
2242
+ scan_ivar_param_assignments(def_node.body, params, {}, credited)
2243
+ end
2244
+ each_node(def_node.body) do |node|
2245
+ name = written_ivar(node)
2246
+ other_writes[prefix.join("::")] << name if name && !credited.include?(node)
2247
+ end
2248
+ end
2249
+
2250
+ # The ivar `node` writes: a write node's name, an `instance_variable_set` on `self`'s literal name, or
2251
+ # `ANY_IVAR` for one whose name the analysis cannot read ({Inference::ReflectiveIvarWrite}).
2252
+ def written_ivar(node)
2253
+ return node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
2254
+ return nil unless Inference::ReflectiveIvarWrite.self_write?(node)
2255
+
2256
+ Inference::ReflectiveIvarWrite.literal_name(node) || ANY_IVAR
2257
+ end
2258
+
2259
+ def each_node(node, &)
2260
+ return unless node.is_a?(Prism::Node)
2261
+
2262
+ yield node
2263
+ node.rigor_each_child { |child| each_node(child, &) }
1863
2264
  end
1864
2265
 
1865
2266
  def collect_init_ivar_obs(node, prefix, result)
@@ -1945,21 +2346,36 @@ module Rigor
1945
2346
 
1946
2347
  # Walk a def body for direct `@ivar = local_var` assignments where `local_var` is one of the listed
1947
2348
  # parameter names. Records ivar_name (Symbol with `@` prefix) → param_name. Does not recurse into nested
1948
- # defs / classes / modules.
1949
- def scan_ivar_param_assignments(node, param_names, result)
2349
+ # defs / classes / modules. A read counts only when it reaches the method's own parameter: its
2350
+ # `LocalVariableReadNode#depth` equals the number of blocks / lambdas between it and the method, so a
2351
+ # block parameter of the same name (`each { |count| @count = count }`) is not the constructor argument
2352
+ # (#1410). `credited`, when given, collects every write node the result is keyed on.
2353
+ def scan_ivar_param_assignments(node, param_names, result, credited = nil, scopes = 0)
1950
2354
  return unless node.is_a?(Prism::Node)
1951
2355
 
1952
- if node.is_a?(Prism::InstanceVariableWriteNode) &&
1953
- node.value.is_a?(Prism::LocalVariableReadNode) &&
1954
- param_names.include?(node.value.name)
1955
- result[node.name] ||= node.value.name
2356
+ ivar, param = param_assignment(node, param_names, scopes)
2357
+ if ivar
2358
+ result[ivar] ||= param
2359
+ credited << node if credited && result[ivar] == param
1956
2360
  end
1957
2361
 
1958
2362
  return if node.is_a?(Prism::DefNode) ||
1959
2363
  node.is_a?(Prism::ClassNode) ||
1960
2364
  node.is_a?(Prism::ModuleNode)
1961
2365
 
1962
- node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result) }
2366
+ scopes += 1 if node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
2367
+ node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result, credited, scopes) }
2368
+ end
2369
+
2370
+ # `[ivar, param]` for `@ivar = param` whose read reaches the method's own parameter `param`, else nil.
2371
+ def param_assignment(node, param_names, scopes)
2372
+ return nil unless node.is_a?(Prism::InstanceVariableWriteNode)
2373
+
2374
+ read = node.value
2375
+ return nil unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == scopes
2376
+ return nil unless param_names.include?(read.name)
2377
+
2378
+ [node.name, read.name]
1963
2379
  end
1964
2380
 
1965
2381
  def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)