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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module SigGen
@@ -27,12 +28,14 @@ module Rigor
27
28
  class LayoutIndex
28
29
  # The `[method_name, kind]` keys a member declaration answers for, `kind` being `:instance` or
29
30
  # `:singleton`. A `def self?.f` answers for both kinds; an `attr_accessor` answers for its reader and its
30
- # writer. Shared with {Writer}, which asks the same question of the target file it is merging into.
31
+ # writer; an `alias new old` (`alias self.new self.old`) answers for its new name only, under its own kind
32
+ # (#1975). Shared with {Writer}, which asks the same question of the target file it is merging into.
31
33
  def self.member_keys(member)
32
34
  case member
33
35
  when RBS::AST::Members::MethodDefinition
34
36
  kinds = member.kind == :singleton_instance ? %i[instance singleton] : [member.kind]
35
37
  kinds.map { |kind| [member.name, kind] }
38
+ when RBS::AST::Members::Alias then [[member.new_name, member.kind]]
36
39
  when RBS::AST::Members::AttrReader then [[member.name, member.kind]]
37
40
  when RBS::AST::Members::AttrWriter then [[:"#{member.name}=", member.kind]]
38
41
  when RBS::AST::Members::AttrAccessor then [[member.name, member.kind], [:"#{member.name}=", member.kind]]
@@ -92,7 +95,7 @@ module Rigor
92
95
  base = Pathname(dir)
93
96
  next unless base.directory?
94
97
 
95
- Dir.glob(File.join(base.to_s, "**/*.rbs"), sort: true).each do |rbs_file|
98
+ RootedGlob.glob(base, "**/*.rbs").each do |rbs_file|
96
99
  index_file(Pathname(rbs_file), accumulator)
97
100
  end
98
101
  end
@@ -10,6 +10,7 @@ require_relative "../source/constant_path"
10
10
  require_relative "../source/literals"
11
11
  require_relative "../source/node_children"
12
12
  require_relative "../inference/scope_indexer"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  module SigGen
@@ -32,6 +33,7 @@ module Rigor
32
33
  # `let` / `subject` bindings ride on slice 5's optional `rigor-rspec` integration.
33
34
  # - Calls whose receiver does not type as a `Type::Nominal` (e.g. `(some_dynamic).bar(...)`) are skipped —
34
35
  # the collector cannot attribute them to a specific class.
36
+ # - A `T | nil` receiver with one `Nominal` / `Singleton` non-nil member counts as that member (#1389).
35
37
  # - Zero-argument calls give no observation; methods are matched by `(class_name, method_name)` only.
36
38
  class ObservationCollector # rubocop:disable Metrics/ClassLength
37
39
  # @param paths — observe paths (files /
@@ -62,6 +64,12 @@ module Rigor
62
64
  observations.transform_values(&:freeze).freeze
63
65
  end
64
66
 
67
+ # Whether `arg` names something {#collect} reads: a directory, or a `.rb` file. The CLI's
68
+ # `observation` report splits roots on this, so "observed" means what the collector does (#1398).
69
+ def self.readable_root?(arg)
70
+ File.directory?(arg) || (File.file?(arg) && arg.end_with?(".rb"))
71
+ end
72
+
65
73
  private
66
74
 
67
75
  # Issue #821 — same universe as the generator and as `rigor check`; see
@@ -74,8 +82,8 @@ module Rigor
74
82
  def resolve_paths(args)
75
83
  args.flat_map do |arg|
76
84
  if File.directory?(arg)
77
- Dir.glob(File.join(arg, "**/*.rb"), sort: true)
78
- elsif File.file?(arg) && arg.end_with?(".rb")
85
+ RootedGlob.glob(arg, "**/*.rb")
86
+ elsif self.class.readable_root?(arg)
79
87
  [arg]
80
88
  else
81
89
  []
@@ -90,10 +98,14 @@ module Rigor
90
98
 
91
99
  base_scope = Scope.empty(environment: environment)
92
100
  base_scope = base_scope.with_discovery(base_scope.discovery.with(discovered_classes: discovered_classes))
93
- scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
101
+ # Guard: sig-gen never reads a declared ivar type back (#1406). No spec pins this flag here, because
102
+ # it only matters for an RBS-declared class defined in an observed spec file.
103
+ scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope,
104
+ declared_ivar_types: false)
94
105
  bindings = collect_rspec_bindings(parse_result.value, scope_index)
106
+ @before_ivars = collect_before_ivars(parse_result.value, scope_index)
95
107
 
96
- walk_calls(parse_result.value, scope_index, bindings, observations)
108
+ walk_calls(parse_result.value, scope_index, bindings, observations, [])
97
109
  end
98
110
 
99
111
  # Pre-walks `@source_paths` to collect every qualified class / module declaration. The result seeds the
@@ -145,22 +157,64 @@ module Rigor
145
157
  end
146
158
  end
147
159
 
148
- def walk_calls(node, scope_index, bindings, observations)
160
+ # `blocks` is the chain of enclosing RSpec group / example / hook blocks, innermost last — the blocks a
161
+ # `before` ivar can reach the call from ({#before_ivar_type}). See {#enclosing_blocks} for where it is cut.
162
+ def walk_calls(node, scope_index, bindings, observations, blocks)
149
163
  return unless node.is_a?(Prism::Node)
150
164
 
151
- record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
152
- node.rigor_each_child { |child| walk_calls(child, scope_index, bindings, observations) }
165
+ record_call(node, scope_index, bindings, observations, blocks) if node.is_a?(Prism::CallNode)
166
+ node.rigor_each_child do |child|
167
+ walk_calls(child, scope_index, bindings, observations, enclosing_blocks(node, child, blocks))
168
+ end
169
+ end
170
+
171
+ # Group, example and hook calls whose block runs with the example's ivars. `RSpec.` may prefix a group call.
172
+ RSPEC_GROUP_CALLS = %i[
173
+ describe context feature shared_examples shared_examples_for shared_context
174
+ ].freeze
175
+ RSPEC_EXAMPLE_OR_HOOK_CALLS = %i[
176
+ it specify example scenario its before prepend_before append_before
177
+ ].freeze
178
+ private_constant :RSPEC_GROUP_CALLS, :RSPEC_EXAMPLE_OR_HOOK_CALLS
179
+
180
+ # The chain `child` of `parent` sees. A group / example / hook call's block extends it; every other block or
181
+ # lambda (`Class.new { … }`, `instance_eval { … }`, `define_method(:m) { … }`, `each { … }`), a class / module /
182
+ # singleton-class body, and a `def` that is not a direct statement of a group block cut it, since their `self`
183
+ # may be another object.
184
+ def enclosing_blocks(parent, child, blocks)
185
+ case child
186
+ when Prism::BlockNode then rspec_scope_call?(parent) ? [*blocks, child] : []
187
+ when Prism::LambdaNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then []
188
+ when Prism::DefNode then group_statement?(child, blocks.last) ? blocks : []
189
+ else blocks
190
+ end
191
+ end
192
+
193
+ def rspec_scope_call?(node)
194
+ return false unless node.is_a?(Prism::CallNode)
195
+ return rspec_group_receiver?(node.receiver) if RSPEC_GROUP_CALLS.include?(node.name)
196
+
197
+ node.receiver.nil? && RSPEC_EXAMPLE_OR_HOOK_CALLS.include?(node.name)
153
198
  end
154
199
 
155
- def record_call(call_node, scope_index, bindings, observations)
200
+ def rspec_group_receiver?(receiver)
201
+ receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
202
+ end
203
+
204
+ def group_statement?(node, block)
205
+ body = block&.body
206
+ body.is_a?(Prism::StatementsNode) && body.body.any? { |statement| statement.equal?(node) }
207
+ end
208
+
209
+ def record_call(call_node, scope_index, bindings, observations, blocks)
156
210
  receiver = call_node.receiver
157
211
  return if receiver.nil?
158
212
 
159
213
  scope = scope_index[call_node] || scope_index[receiver]
160
214
  return if scope.nil?
161
215
 
162
- receiver_type = resolve_receiver_type(receiver, scope, bindings)
163
- key = observation_key(call_node, receiver_type)
216
+ receiver_type = resolve_receiver_type(receiver, scope, bindings, blocks)
217
+ key = observation_key(call_node, observed_receiver(receiver_type, call_node, scope))
164
218
  return if key.nil?
165
219
 
166
220
  observation = collect_args(call_node, scope)
@@ -181,6 +235,36 @@ module Rigor
181
235
  end
182
236
  end
183
237
 
238
+ # Issue #1389 — a receiver typed `T | nil` whose one non-nil member is a `Nominal` or `Singleton` is observed as
239
+ # that member. A Minitest `setup` ivar read in a test method types as `Foo?` (setup is not `initialize`), and a
240
+ # call of a method `nil` does not answer can only have reached `Foo#bar` with its arguments: on `nil` it raises
241
+ # `NoMethodError`, and `&.` skips the call. A method `NilClass`'s RBS declares (`==`, `respond_to?`, `is_a?`)
242
+ # may have run on `nil` instead, so a plain `.` call of one keeps the union unobserved. The sole member still
243
+ # has to pass {#observation_key}'s `Nominal` / `Singleton` checks, so `untyped | nil` stays unobserved, and a
244
+ # union with two non-nil members (`Foo | Bar | nil`) passes through unchanged. Observation only: the
245
+ # receiver's inferred type elsewhere is untouched.
246
+ def observed_receiver(receiver_type, call_node, scope)
247
+ return receiver_type unless receiver_type.is_a?(Type::Union)
248
+
249
+ non_nil = receiver_type.members.reject { |member| nil_type?(member) }
250
+ return receiver_type unless non_nil.size == 1
251
+ return receiver_type if !call_node.safe_navigation? && nil_answers?(call_node.name, scope)
252
+
253
+ non_nil.first
254
+ end
255
+
256
+ # Whether `nil` may answer `method_name`: `NilClass`'s RBS declares it, or that RBS is not available to ask.
257
+ def nil_answers?(method_name, scope)
258
+ loader = scope.environment.rbs_loader
259
+ return true if loader.nil? || loader.instance_definition("NilClass").nil?
260
+
261
+ !loader.instance_method(class_name: "NilClass", method_name: method_name).nil?
262
+ end
263
+
264
+ def nil_type?(type)
265
+ (type.is_a?(Type::Constant) && type.value.nil?) || (type.is_a?(Type::Nominal) && type.class_name == "NilClass")
266
+ end
267
+
184
268
  # ADR-14 slice 5 — RSpec-aware receiver typing. Resolves a CallNode receiver against the collected
185
269
  # `bindings` map (built by {#collect_rspec_bindings}) before falling back to ordinary `scope.type_of`. The
186
270
  # three RSpec-shaped receivers we recognise:
@@ -190,11 +274,23 @@ module Rigor
190
274
  # - `described_class.new(...)` chain — when the surrounding `describe Foo do … end` resolved `Foo`,
191
275
  # return `Type::Nominal[Foo]`.
192
276
  # - Anything else — pass through to `scope.type_of`, matching slice-3 behaviour.
193
- def resolve_receiver_type(receiver, scope, bindings)
277
+ def resolve_receiver_type(receiver, scope, bindings, blocks)
194
278
  return resolve_described_class_new(bindings) if described_class_new?(receiver)
195
279
  return bindings[receiver.name] if bound_call?(receiver, bindings)
196
280
 
197
- safe_type_of(scope, receiver)
281
+ type = safe_type_of(scope, receiver)
282
+ return type unless receiver.is_a?(Prism::InstanceVariableReadNode) && (type.nil? || type.is_a?(Type::Dynamic))
283
+
284
+ before_ivar_type(receiver.name, blocks) || type
285
+ end
286
+
287
+ # Issue #1389 — an ivar read inside an `it` block types as `Dynamic` (the block's ivars are not a class's), so
288
+ # a `before { @foo = Foo.new }` write stands in for it. Only a `before` whose example-group block encloses the
289
+ # read counts — a sibling `describe`, an un-included `shared_context` or a class body never reaches it — and
290
+ # when those `before`s write the name with more than one type, no type is answered.
291
+ def before_ivar_type(name, blocks)
292
+ types = blocks.flat_map { |block| @before_ivars.dig(block, name) || [] }.uniq
293
+ types.size == 1 ? types.first : nil
198
294
  end
199
295
 
200
296
  def bound_call?(receiver, bindings)
@@ -279,6 +375,39 @@ module Rigor
279
375
  bindings[name] = body_type if body_type
280
376
  end
281
377
 
378
+ # `before { @foo = Foo.new }` / `before(:each) do … end` inside an example-group block: records each ivar the
379
+ # `before` block's top-level statements write, with the written value's type, under that enclosing group
380
+ # block — `{ BlockNode => { :@foo => [Type] } }`, keyed by node identity. A `before` outside every group
381
+ # block ({#enclosing_blocks}), and `config.before`, record nothing.
382
+ def collect_before_ivars(root, scope_index)
383
+ index = {}.compare_by_identity
384
+ walk_before_ivars(root, [], index, scope_index)
385
+ index
386
+ end
387
+
388
+ def walk_before_ivars(node, blocks, index, scope_index)
389
+ return unless node.is_a?(Prism::Node)
390
+
391
+ record_before_ivars(node, blocks.last, index, scope_index) if blocks.last
392
+ node.rigor_each_child do |child|
393
+ walk_before_ivars(child, enclosing_blocks(node, child, blocks), index, scope_index)
394
+ end
395
+ end
396
+
397
+ def record_before_ivars(node, group, index, scope_index)
398
+ return unless node.is_a?(Prism::CallNode) && node.name == :before && node.receiver.nil?
399
+
400
+ body = block_body_node(node.block)
401
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body].compact
402
+ statements.each do |statement|
403
+ next unless statement.is_a?(Prism::InstanceVariableWriteNode)
404
+
405
+ scope = scope_index[statement.value] || scope_index[statement]
406
+ type = scope && safe_type_of(scope, statement.value)
407
+ ((index[group] ||= {})[statement.name] ||= []) << type if type
408
+ end
409
+ end
410
+
282
411
  def binding_name_for(call_node)
283
412
  first_arg = call_node.arguments&.arguments&.first
284
413
  return call_node.name == :subject ? :subject : nil if first_arg.nil?
@@ -7,7 +7,7 @@ module Rigor
7
7
  # Parses what sig-gen is about to emit, before it is emitted.
8
8
  #
9
9
  # Two rendering bugs have shipped RBS that `rbs` itself rejects — a non-identifier record key
10
- # (`{ :"data-contrast" => T }`) and a `&block` constructor parameter rendered before the parens
10
+ # (`{ "data-contrast": T }`) and a `&block` constructor parameter rendered before the parens
11
11
  # (`(**untyped, ?{ (?) -> void })`). Both were found downstream, as a poisoned `sig/` tree: the file is
12
12
  # quarantined, its types vanish, and (for a project adopting `reject-unparseable-signatures`) the build
13
13
  # fails. A generator that can poison its own consumer's build is worth guarding at the source.
@@ -4,6 +4,7 @@ require "json"
4
4
 
5
5
  require_relative "classification"
6
6
  require_relative "superclass_spelling"
7
+ require_relative "write_reporter"
7
8
 
8
9
  module Rigor
9
10
  module SigGen
@@ -26,9 +27,13 @@ module Rigor
26
27
  # @param selection — subset of
27
28
  # {Classification} constants to include; an empty
28
29
  # array means "all emittable classifications".
29
- def render(candidates:, mode:, format:, selection:)
30
+ # @param observation — `{ paths:, missing: }` under `--params=observed`, else `nil`; JSON carries it
31
+ # as the top-level `observation` key (#1398).
32
+ # @param diagnostics — the path errors `rigor check` would report for the arguments (#1991); JSON carries
33
+ # them as the top-level `diagnostics` key, and only when there are some.
34
+ def render(candidates:, mode:, format:, selection:, observation: nil, diagnostics: [])
30
35
  case format
31
- when "json" then render_json(filter(candidates, selection, with_skipped: true))
36
+ when "json" then render_json(filter(candidates, selection, with_skipped: true), observation, diagnostics)
32
37
  when "text"
33
38
  filtered = filter(candidates, selection)
34
39
  mode == :diff ? render_diff(filtered) : render_print(filtered)
@@ -121,175 +126,31 @@ module Rigor
121
126
  end
122
127
  end
123
128
 
124
- def render_json(candidates)
129
+ def render_json(candidates, observation = nil, diagnostics = [])
125
130
  payload = { candidates: candidates.map(&:to_h) }
131
+ payload[:observation] = observation if observation
132
+ payload[:diagnostics] = diagnostics.map(&:to_h) if diagnostics.any?
126
133
  @out.puts(JSON.pretty_generate(payload))
127
134
  end
128
135
 
129
136
  public
130
137
 
131
- # Renders the per-source-file outcomes of a `--write` run. Distinct from {#render} because the write
132
- # path's reporting surface is action-oriented (created / updated / skipped) rather than candidate-oriented.
133
- # @param refused — methods refused as `sig.skipped.inline-differs`; the JSON payload names them under
134
- # `refused` (absent when there are none, so an ordinary payload is unchanged), and text mode leaves them
135
- # to the command's stderr `REFUSED` lines.
138
+ # The per-source-file outcomes of a `--write` run, and the `--check` verdict, are reported by
139
+ # {WriteReporter}; these delegate so the command keeps one renderer.
136
140
  def render_write(results:, format:, refused: [])
137
- case format
138
- when "json" then render_write_json(results, refused)
139
- when "text" then render_write_text(results)
140
- else raise ArgumentError, "unsupported format: #{format}"
141
- end
141
+ WriteReporter.new(out: @out).render_write(results: results, format: format, refused: refused)
142
142
  end
143
143
 
144
- # ADR-112 WD4 — `sig-gen --check`: the results of a dry-run `--write`. Only the targets `--write` would
145
- # change (or refuse) are shown, each with the lines it would add; the verdict is the exit status, which
146
- # the command derives from the same results ({.out_of_date}).
147
- #
148
- # @param refused — the methods the generator refused to reconcile (`sig.skipped.inline-differs`):
149
- # `sig/` is not up to date while one stands, and `--write` cannot fix it.
150
144
  def render_check(results:, format:, refused: [])
151
- stale = self.class.out_of_date(results)
152
- case format
153
- when "json"
154
- @out.puts(JSON.pretty_generate({ up_to_date: stale.empty? && refused.empty?,
155
- results: stale.map { |r| check_entry(r) },
156
- refused: refused.map(&:to_h) }))
157
- when "text" then render_check_text(stale, refused)
158
- else raise ArgumentError, "unsupported format: #{format}"
159
- end
145
+ WriteReporter.new(out: @out).render_check(results: results, format: format, refused: refused)
160
146
  end
161
147
 
162
- # One line per method sig-gen refused to reconcile with its `sig/` copy. Shared by `--write` (on stderr,
163
- # next to the write report) and `--check`.
164
148
  def self.refusal_lines(refused)
165
- refused.map do |candidate|
166
- separator = candidate.kind == :singleton ? "." : "#"
167
- "REFUSED #{candidate.path}: #{candidate.class_name}#{separator}#{candidate.method_name} — its inline " \
168
- "declaration and its sig/ declaration disagree, so neither was changed " \
169
- "(#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}). Make them agree, or pass " \
170
- "--overwrite to replace the sig/ member with the inline declaration; `rigor explain " \
171
- "#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}` says when that is not enough."
172
- end
149
+ WriteReporter.refusal_lines(refused)
173
150
  end
174
151
 
175
- # The results that make a `--check` fail: a target `--write` would create or change, and one it would
176
- # refuse, since a write that cannot happen is not an up-to-date `sig/` either.
177
152
  def self.out_of_date(results)
178
- results.reject { |result| %i[noop skipped_outside_sig_root].include?(result.action) }
179
- end
180
-
181
- # Nothing was written, so the entry must not read like one that was: `created` / `updated` become
182
- # `would_create` / `would_update`. A refusal keeps its action — `--write` would refuse the same way.
183
- CHECK_ACTIONS = { created: "would_create", updated: "would_update" }.freeze
184
- private_constant :CHECK_ACTIONS
185
-
186
- private
187
-
188
- def check_entry(result)
189
- entry = result.to_h
190
- entry[:action] = CHECK_ACTIONS.fetch(result.action, entry[:action])
191
- entry
192
- end
193
-
194
- def render_check_text(stale, refused)
195
- if stale.empty? && refused.empty?
196
- @out.puts("sig/ is up to date")
197
- return
198
- end
199
-
200
- self.class.refusal_lines(refused).each { |line| @out.puts(line) }
201
-
202
- stale.each do |result|
203
- case result.action
204
- when :created, :updated then render_check_change(result)
205
- when :skipped_invalid_rbs then render_write_invalid(result)
206
- when :skipped_invalid_encoding then render_write_invalid_encoding(result)
207
- end
208
- end
209
- end
210
-
211
- def render_check_change(result)
212
- counts = result.action == :created ? "#{result.applied.size} method(s)" : applied_counts(result)
213
- verb = result.action == :created ? "would create" : "would update"
214
- @out.puts("#{verb} #{result.target_path} (#{counts})")
215
- result.applied.each do |candidate|
216
- @out.puts(" - #{candidate.declared_rbs}") if candidate.declared_rbs
217
- candidate.rbs_lines.each { |line| @out.puts(" + #{line}") }
218
- end
219
- end
220
-
221
- def render_write_text(results)
222
- if results.all? { |r| r.action == :noop }
223
- @out.puts("No changes")
224
- return
225
- end
226
-
227
- results.each do |result|
228
- case result.action
229
- when :created then render_write_created(result)
230
- when :updated then render_write_updated(result)
231
- when :skipped_outside_sig_root then render_write_skipped(result)
232
- when :skipped_invalid_rbs then render_write_invalid(result)
233
- when :skipped_invalid_encoding then render_write_invalid_encoding(result)
234
- end
235
- end
236
- end
237
-
238
- # `+N` counts added lines; an existing line replaced (`--overwrite`, or an inline update) is counted apart,
239
- # because "added 2" when one of them rewrote a line the project already had would understate the change.
240
- def applied_counts(result)
241
- added = "+#{result.applied.size - result.replaced.size}"
242
- result.replaced.empty? ? added : "#{added}, replaced #{result.replaced.size}"
243
- end
244
-
245
- def render_write_created(result)
246
- @out.puts("created #{result.target_path} (#{result.applied.size} method(s))")
247
- end
248
-
249
- def render_write_updated(result)
250
- @out.puts("updated #{result.target_path} (#{applied_counts(result)}, " \
251
- "skipped #{result.skipped.size} user-authored)")
252
- render_left_unreadable(result)
253
- end
254
-
255
- # ADR-103 WD9 — the declarations whose annotation region the writer refused to rewrite. Named per
256
- # method rather than counted: the fix is a human reading one existing annotation and deciding what
257
- # it should say, and there is no count of those a reader could act on.
258
- def render_left_unreadable(result)
259
- return if result.left_unreadable.empty?
260
-
261
- @out.puts(" left #{result.left_unreadable.size} existing annotation(s) byte-untouched " \
262
- "(sig.effect.left-unreadable):")
263
- result.left_unreadable.each do |candidate|
264
- @out.puts(" #{candidate.class_name}##{candidate.method_name} — " \
265
- "would have emitted #{candidate.annotations.join(' ')}")
266
- end
267
- end
268
-
269
- def render_write_skipped(result)
270
- @out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
271
- end
272
-
273
- # The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
274
- # consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
275
- # including the user's own hand-written ones.
276
- def render_write_invalid(result)
277
- @out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
278
- @out.puts(" #{result.error}")
279
- @out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
280
- end
281
-
282
- # The EXISTING target file is not valid UTF-8, so the writer will not merge into it — the opposite
283
- # attribution from {#render_write_invalid}: this one is the file's problem, and the fix is the user's.
284
- def render_write_invalid_encoding(result)
285
- @out.puts("REFUSED #{result.target_path} — the existing file is not valid UTF-8, so it was not updated")
286
- @out.puts(" Re-save the file as UTF-8 and re-run; sig-gen never modifies a file it cannot read faithfully.")
287
- end
288
-
289
- def render_write_json(results, refused)
290
- payload = { results: results.map(&:to_h) }
291
- payload[:refused] = refused.map(&:to_h) unless refused.empty?
292
- @out.puts(JSON.pretty_generate(payload))
153
+ WriteReporter.out_of_date(results)
293
154
  end
294
155
  end
295
156
  end
@@ -147,6 +147,17 @@ module Rigor
147
147
  "not reported.",
148
148
  next_step: "Nothing is required. To type the method, move it into a module (`module_function` or " \
149
149
  "`extend self` keeps the call sites), or write the `Object` declaration by hand in `sig/`."
150
+ ),
151
+ "sig.skipped.uncertain-module-function" => Entry.new(
152
+ id: "sig.skipped.uncertain-module-function",
153
+ summary: "Whether the method is a module function depends on a visibility call that may not run.",
154
+ explanation: "A bare `module_function` makes the module's later methods module functions, and a later " \
155
+ "bare `public`, `private` or `protected` ends that. Here such a call sits in control " \
156
+ "flow or a block between the two, so it may or may not run, and the method is either a " \
157
+ "module function (`def self?.name`) or an instance method only (`def name`). sig-gen " \
158
+ "writes neither rather than guess (#1968).",
159
+ next_step: "Move the visibility call to the module body's own statements, or name the methods it " \
160
+ "should cover (`public :name`, `module_function :name`), so the reading is certain."
150
161
  )
151
162
  }.freeze
152
163
 
@@ -37,7 +37,17 @@ module Rigor
37
37
  end
38
38
  end
39
39
 
40
+ # A set is mutable, so the classes the engine reads off its construction (`Set[x]`, `Set.new(xs)`) are not
41
+ # a contract: `x = Set[1]; x << "a"; x` holds a String. A written `Set` therefore keeps `untyped` for its
42
+ # element, as a Set constant does (#2177), however precisely the engine typed it.
43
+ SET_ELEMENT_UNTYPED = "Set"
44
+ private_constant :SET_ELEMENT_UNTYPED
45
+
40
46
  def self.elaborate_nominal(type, environment, arity_cache)
47
+ if type.class_name == SET_ELEMENT_UNTYPED && type.type_args.size == 1
48
+ return Type::Combinator.nominal_of(type.class_name, type_args: [Type::Combinator.untyped])
49
+ end
50
+
41
51
  elaborated_args = type.type_args.map { |arg| walk(arg, environment, arity_cache) }
42
52
  return Type::Combinator.nominal_of(type.class_name, type_args: elaborated_args) if elaborated_args.any?
43
53
 
@@ -57,11 +67,17 @@ module Rigor
57
67
  Type::Tuple.new(elements)
58
68
  end
59
69
 
60
- def self.elaborate_hash_shape(type, _environment, _arity_cache)
61
- # HashShape's element types are read-only on the carrier; rebuilding them would need going through the
62
- # per-pair required/optional/read-only machinery. Sig-gen only routes top-level returns through here for
63
- # now — nested HashShape elaboration ships as a follow-up if the need surfaces.
64
- type
70
+ # A bare generic in a value position (`{ k: Hash.new }`) fails `rbs validate` as a top-level one does
71
+ # (#2177), so the values are elaborated too. Only the pairs change; the key policy is carried over.
72
+ def self.elaborate_hash_shape(type, environment, arity_cache)
73
+ pairs = type.pairs.transform_values { |value| walk(value, environment, arity_cache) }
74
+ return type if pairs == type.pairs
75
+
76
+ Type::HashShape.new(
77
+ pairs,
78
+ required_keys: type.required_keys, optional_keys: type.optional_keys,
79
+ read_only_keys: type.read_only_keys, extra_keys: type.extra_keys
80
+ )
65
81
  end
66
82
 
67
83
  def self.generic_arity_for(class_name, environment, cache)