rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../reflection"
6
+ require_relative "../source/constant_path"
6
7
  require_relative "../type"
7
8
  require_relative "../environment"
8
9
  require_relative "../rbs_extended"
@@ -41,7 +42,13 @@ module Rigor
41
42
  TRUSTED_EQUALITY_LITERAL_CLASSES = [String, Symbol, Integer, TrueClass, FalseClass, NilClass].freeze
42
43
  SINGLETON_LITERAL_CLASSES = [TrueClass, FalseClass, NilClass].freeze
43
44
  ClassNarrowingContext = Data.define(:exact, :polarity, :environment)
44
- private_constant :TRUSTED_EQUALITY_LITERAL_CLASSES, :SINGLETON_LITERAL_CLASSES, :ClassNarrowingContext
45
+ # A recognised `=~` regex pattern operand: the pattern `source` string and whether it was
46
+ # compiled in extended (`//x`) mode. Extraction from a literal `RegularExpressionNode` and from
47
+ # a value-pinned `Constant[Regexp]` share this carrier so the participation walk and the
48
+ # extended-mode bail read one shape.
49
+ RegexMatchPattern = Data.define(:source, :extended)
50
+ private_constant :TRUSTED_EQUALITY_LITERAL_CLASSES, :SINGLETON_LITERAL_CLASSES, :ClassNarrowingContext,
51
+ :RegexMatchPattern
45
52
 
46
53
  module_function
47
54
 
@@ -873,6 +880,15 @@ module Rigor
873
880
  string_predicate_result = analyse_string_predicate(node, scope)
874
881
  return apply_safe_nav_non_nil(node, scope, string_predicate_result) if string_predicate_result
875
882
 
883
+ # A predicate whose RBS says it always returns `false` for one union arm cannot have
884
+ # been called on that arm when it answered truthily. `NilClass#present?: () -> false`
885
+ # (ActiveSupport) is the motivating case: `if login.present?` must narrow `String | nil`
886
+ # to `String`, and nothing else in the catalogue does it — `present?` is a gem method the
887
+ # engine must not hardcode, and `rigor:v1:predicate-if-true` facts never reach a union
888
+ # receiver.
889
+ polarity_result = analyse_union_predicate_polarity(node, scope)
890
+ return polarity_result if polarity_result
891
+
876
892
  # A safe-navigation call (`v&.foo`) whose result is truthy proves the receiver was
877
893
  # non-nil — `&.` returns `nil` when the receiver is nil, so a truthy outcome can
878
894
  # only come from a non-nil receiver. Narrow the receiver on the truthy edge even
@@ -953,8 +969,8 @@ module Rigor
953
969
  end
954
970
 
955
971
  def simple_dispatch_name?(name)
956
- %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?
957
- respond_to?].include?(name)
972
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ match? key? has_key? empty? any?
973
+ none? respond_to?].include?(name)
958
974
  end
959
975
 
960
976
  def dispatch_call_simple(node, scope, name)
@@ -965,6 +981,7 @@ module Rigor
965
981
  when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
966
982
  when :=== then analyse_case_equality_predicate(node, scope)
967
983
  when :=~ then analyse_regex_match_predicate(node, scope)
984
+ when :match? then analyse_whole_regex_match_predicate(node, scope)
968
985
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
969
986
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
970
987
  when :respond_to? then analyse_respond_to_predicate(node, scope)
@@ -1190,30 +1207,123 @@ module Rigor
1190
1207
  # - Falsey edge (`=~` returned nil — no match): `$~` and every numbered / back-reference
1191
1208
  # global bound to `Constant<nil>`.
1192
1209
  #
1193
- # Returns nil (no narrowing) when the receiver / argument pair does not include a
1194
- # `RegularExpressionNode` literal we can count.
1210
+ # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1211
+ # regex pattern we can count (see {#regex_match_pattern}).
1195
1212
  def analyse_regex_match_predicate(node, scope)
1196
1213
  return nil if node.arguments.nil?
1197
1214
  return nil unless node.arguments.arguments.size == 1
1198
1215
 
1199
- regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
1200
- return nil if regex_node.nil?
1216
+ arg = node.arguments.arguments.first
1217
+ pattern = regex_match_pattern(node.receiver, arg, scope)
1218
+ return nil if pattern.nil?
1219
+ # Extended mode (`//x`) lets the pattern carry free whitespace and `#` comments, and a
1220
+ # comment may contain a literal `(` — which the light char-scan walker would miscount as a
1221
+ # capturing group, shifting `$N` indices and narrowing the wrong global. That is a
1222
+ # false-negative-class misnarrowing, so bail rather than risk it. The literal path shares
1223
+ # this bail: `RegularExpressionNode` carries the same latent miscount.
1224
+ return nil if pattern.extended
1225
+
1226
+ unconditional = unconditional_capture_groups(pattern.source)
1227
+ truthy, falsey = regex_match_predicate_scopes(scope, unconditional)
1228
+ # #164 — layer whole-receiver refinement onto the truthy edge without disturbing the
1229
+ # match-global logic above. A successful `str =~ /\A\d+\z/` proves the WHOLE string
1230
+ # matched, so the string operand narrows to the imported refinement the anchored pattern
1231
+ # names. The falsey edge is untouched: a failed match proves nothing about the shape.
1232
+ truthy = apply_whole_receiver_refinement(truthy, node.receiver, arg, pattern.source, scope)
1233
+ [truthy, falsey]
1234
+ end
1235
+
1236
+ # `str.match?(/\A\d+\z/)` — `String#match?` is a pure boolean predicate whose truthy edge
1237
+ # proves a property of the ENTIRE receiver. When the argument is a fully `\A…\z`-anchored
1238
+ # single-char-class pattern recognised by {RegexRefinement.for_whole_pattern}, narrow the
1239
+ # receiver local to the matching imported refinement on the truthy edge; the falsey edge
1240
+ # passes through unchanged (failing one pattern proves nothing about the string's shape).
1241
+ # A non-literal / unanchored / `\Z` / line-anchored / `//x` pattern falls through to no
1242
+ # narrowing.
1243
+ def analyse_whole_regex_match_predicate(node, scope)
1244
+ return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
1245
+ return nil if node.arguments.nil? || node.arguments.arguments.size != 1
1246
+
1247
+ pattern = regex_operand_pattern(node.arguments.arguments.first, scope)
1248
+ return nil unless pattern.is_a?(RegexMatchPattern)
1249
+ return nil if pattern.extended
1201
1250
 
1202
- unconditional = unconditional_capture_groups(regex_node.unescaped)
1203
- regex_match_predicate_scopes(scope, unconditional)
1251
+ refinement = ::Rigor::Builtins::RegexRefinement.for_whole_pattern(pattern.source)
1252
+ return nil if refinement.nil?
1253
+
1254
+ local_name = node.receiver.name
1255
+ return nil if scope.local(local_name).nil?
1256
+
1257
+ [scope.with_local(local_name, refinement), scope]
1204
1258
  end
1205
1259
 
1206
- def regex_match_literal(left, right)
1207
- return left if left.is_a?(Prism::RegularExpressionNode)
1208
- return right if right.is_a?(Prism::RegularExpressionNode)
1260
+ # Narrows the string operand of a `=~` predicate to a whole-receiver refinement when the
1261
+ # regex source is a recognised `\A…\z`-anchored pattern. Returns the (possibly unchanged)
1262
+ # truthy scope; the falsey edge is never touched by this layer.
1263
+ def apply_whole_receiver_refinement(truthy, receiver, arg, source, scope)
1264
+ refinement = ::Rigor::Builtins::RegexRefinement.for_whole_pattern(source)
1265
+ return truthy if refinement.nil?
1209
1266
 
1210
- nil
1267
+ # `regex_match_pattern` already resolved the regex operand from a literal or a
1268
+ # `Constant[Regexp]` constant read — never a bare local — so the string subject is
1269
+ # unambiguously the local-read operand, if either operand is one.
1270
+ subject = [receiver, arg].find { |operand| operand.is_a?(Prism::LocalVariableReadNode) }
1271
+ return truthy if subject.nil? || scope.local(subject.name).nil?
1272
+
1273
+ truthy.with_local(subject.name, refinement)
1274
+ end
1275
+
1276
+ # Recognises the regex pattern operand of a `=~` predicate. An operand resolves to a regex when
1277
+ # it is either a syntactic `RegularExpressionNode` literal OR a constant read whose type is a
1278
+ # value-pinned `Constant[Regexp]` (`RE = /.../` and `RE = Regexp.new(...)` both fold to the
1279
+ # carrier in the whole-program constant pre-pass). Returns a single {RegexMatchPattern}, or nil
1280
+ # (no narrowing) when NEITHER operand resolves to a regex, when BOTH do (`/a/ =~ /b/` — no
1281
+ # string subject to bind), or when a constant operand is ambiguous — typed as a `Union` from a
1282
+ # twice-assigned constant, where no single source can be pinned.
1283
+ def regex_match_pattern(left, right, scope)
1284
+ left_pattern = regex_operand_pattern(left, scope)
1285
+ right_pattern = regex_operand_pattern(right, scope)
1286
+ return nil if left_pattern == :ambiguous || right_pattern == :ambiguous
1287
+
1288
+ patterns = [left_pattern, right_pattern].grep(RegexMatchPattern)
1289
+ patterns.size == 1 ? patterns.first : nil
1290
+ end
1291
+
1292
+ # Classifies one `=~` operand: a {RegexMatchPattern} when it resolves to a regex (literal or
1293
+ # `Constant[Regexp]`), `:ambiguous` when a constant operand types as a `Union` (a twice-assigned
1294
+ # constant), or nil when the operand is not a regex at all.
1295
+ def regex_operand_pattern(node, scope)
1296
+ case node
1297
+ when Prism::RegularExpressionNode
1298
+ RegexMatchPattern.new(source: node.unescaped, extended: node.extended?)
1299
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1300
+ regex_operand_from_constant(node, scope)
1301
+ end
1211
1302
  end
1212
1303
 
1213
- # Curated set of back-reference globals bound by every
1214
- # `=~`. Numbered references (`$1..$N`) are handled
1215
- # separately because N depends on the regex source.
1216
- REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
1304
+ # Resolves a constant-reference operand to a regex pattern via the shared lexical-constant
1305
+ # resolution (`Reflection.resolve_constant_type`). `Regexp#options` gives the real
1306
+ # extended-mode flag; `Regexp#source` the pattern. Returns `:ambiguous` for a `Union`-typed
1307
+ # constant so the caller bails, and nil when the constant does not resolve to a `Constant[Regexp]`.
1308
+ def regex_operand_from_constant(node, scope)
1309
+ name = Source::ConstantPath.qualified_name_or_nil(node)
1310
+ return nil if name.nil?
1311
+
1312
+ type = Reflection.resolve_constant_type(name, scope: scope)
1313
+ return :ambiguous if type.is_a?(Type::Union)
1314
+ return nil unless type.is_a?(Type::Constant)
1315
+
1316
+ value = type.value
1317
+ return nil unless value.is_a?(Regexp)
1318
+
1319
+ RegexMatchPattern.new(source: value.source, extended: value.options.anybits?(Regexp::EXTENDED))
1320
+ end
1321
+
1322
+ # Curated set of match globals bound non-nil on every successful `=~` regardless of grouping:
1323
+ # `$~` (the MatchData), `$&` (whole match), `` $` `` (pre-match), `$'` (post-match). Numbered
1324
+ # references (`$1..$N`) and `$+` (the last matched group) are handled separately because they
1325
+ # depend on the regex source — `$+` is gated on the same participation set as `$N` (#177).
1326
+ REGEX_MATCH_GLOBALS = %i[$~ $& $` $'].freeze
1217
1327
  private_constant :REGEX_MATCH_GLOBALS
1218
1328
 
1219
1329
  # `unconditional` is the Set of 1-based numbered-capture indices whose group is
@@ -1242,6 +1352,15 @@ module Rigor
1242
1352
  truthy = truthy.with_global(name, string_t)
1243
1353
  falsey = falsey.with_global(name, nil_t)
1244
1354
  end
1355
+ # `$+` is the LAST matched group, so it is nil on a successful match whose groups are all
1356
+ # optional (`"b" =~ /(a)?b/` matches yet leaves `$+` nil) or absent (no capture group). It
1357
+ # narrows to `String` only when some group is guaranteed to participate — the same
1358
+ # participation gate `$N` uses (#177). With no unconditional group it is left unbound on both
1359
+ # edges (fall through to the RBS `String?` default), exactly as an optional `$N` is.
1360
+ unless unconditional.empty?
1361
+ truthy = truthy.with_global(:$+, string_t)
1362
+ falsey = falsey.with_global(:$+, nil_t)
1363
+ end
1245
1364
  [truthy, falsey]
1246
1365
  end
1247
1366
 
@@ -2352,6 +2471,115 @@ module Rigor
2352
2471
  end
2353
2472
  end
2354
2473
 
2474
+ # The three value-pinned classes a union arm can name whose instances are the single
2475
+ # value itself. Their finality is what makes the polarity rule below sound: no subclass
2476
+ # can override the predicate, because there is no subclass.
2477
+ POLARITY_ARM_CLASSES = { nil => "NilClass", true => "TrueClass", false => "FalseClass" }.freeze
2478
+ private_constant :POLARITY_ARM_CLASSES
2479
+
2480
+ # Drops union arms a zero-argument predicate's own signature rules out on each edge.
2481
+ #
2482
+ # `if x.present?` with `x: String | nil` must see `String` in the body. Rigor could not:
2483
+ # `present?` is an ActiveSupport method (so the engine must not join the hardcoded
2484
+ # `nil?` / `empty?` catalogue with it), and `resolve_rbs_extended_method` hands a union
2485
+ # receiver no `rigor:v1:predicate-if-true` facts, so annotating the RBS would not help
2486
+ # either. But the answer is already written down: the bundled ActiveSupport signature
2487
+ # declares `NilClass#present?: () -> false`, and a method that always returns `false` for
2488
+ # `nil` cannot have answered truthily on a `nil` receiver.
2489
+ #
2490
+ # Soundness rests on the arm being a value-pinned `nil` / `true` / `false`. For an
2491
+ # ordinary `Nominal[Foo]` arm the static type admits Foo's subclasses, any of which may
2492
+ # override the predicate and return the other polarity; the three classes here have no
2493
+ # subclasses, so the declared return is the runtime return.
2494
+ #
2495
+ # Safe navigation is excluded: `x&.blank?` yields `nil` (falsey) for a nil receiver
2496
+ # rather than `NilClass#blank?`'s declared `true`, so the falsey edge would wrongly drop
2497
+ # the nil arm. `analyse_safe_nav_receiver` below handles that shape's truthy edge.
2498
+ #
2499
+ # Returns `[truthy_scope, falsey_scope]`, or nil when no arm is ruled out, when an edge
2500
+ # would be emptied, or when the receiver has no scope binding to narrow.
2501
+ def analyse_union_predicate_polarity(node, scope)
2502
+ return nil if node.safe_navigation? || !argument_free?(node)
2503
+ return nil unless node.name.end_with?("?")
2504
+
2505
+ receiver_type = receiver_binding_type(node.receiver, scope)
2506
+ return nil unless receiver_type.is_a?(Type::Union)
2507
+
2508
+ truthy, falsey = partition_arms_by_polarity(receiver_type.members, node.name, scope)
2509
+ return nil if truthy.size == receiver_type.members.size && falsey.size == receiver_type.members.size
2510
+ return nil if truthy.empty? || falsey.empty?
2511
+
2512
+ [
2513
+ narrow_receiver_binding(node.receiver, scope, Type::Combinator.union(*truthy)),
2514
+ narrow_receiver_binding(node.receiver, scope, Type::Combinator.union(*falsey))
2515
+ ]
2516
+ end
2517
+
2518
+ # Splits `members` into the arms that survive the truthy edge and those that survive the
2519
+ # falsey edge. An arm whose polarity is unknown survives both.
2520
+ def partition_arms_by_polarity(members, method_name, scope)
2521
+ truthy = []
2522
+ falsey = []
2523
+ members.each do |arm|
2524
+ case arm_predicate_polarity(arm, method_name, scope)
2525
+ when :always_false then falsey << arm
2526
+ when :always_true then truthy << arm
2527
+ else
2528
+ truthy << arm
2529
+ falsey << arm
2530
+ end
2531
+ end
2532
+ [truthy, falsey]
2533
+ end
2534
+
2535
+ # `:always_false` / `:always_true` when every RBS overload of `method_name` on the arm's
2536
+ # class returns that literal; nil for any other arm, signature, or lookup failure.
2537
+ def arm_predicate_polarity(arm, method_name, scope)
2538
+ returns = arm_predicate_return_types(arm, method_name, scope)
2539
+ return nil if returns.nil? || returns.empty?
2540
+ return :always_false if returns.all? { |type| literal_boolean?(type, false) }
2541
+ return :always_true if returns.all? { |type| literal_boolean?(type, true) }
2542
+
2543
+ nil
2544
+ end
2545
+
2546
+ # Declared return type of every RBS overload of `method_name` on the arm's class, or nil
2547
+ # when the arm is not one of the three value-pinned classes or the lookup fails.
2548
+ def arm_predicate_return_types(arm, method_name, scope)
2549
+ return nil unless arm.is_a?(Type::Constant)
2550
+
2551
+ class_name = POLARITY_ARM_CLASSES[arm.value]
2552
+ return nil if class_name.nil?
2553
+
2554
+ definition = Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
2555
+ definition&.method_types&.map { |method_type| method_type.type.return_type }
2556
+ rescue StandardError
2557
+ nil
2558
+ end
2559
+
2560
+ def literal_boolean?(rbs_type, value)
2561
+ rbs_type.is_a?(RBS::Types::Literal) && rbs_type.literal == value
2562
+ end
2563
+
2564
+ # The four receiver shapes with a scope binding the edges can rebind, mirroring
2565
+ # `apply_self_fact`. A method chain or arbitrary expression has none, so it reads nil and
2566
+ # the caller declines.
2567
+ def receiver_binding_type(receiver, scope)
2568
+ case receiver
2569
+ when Prism::LocalVariableReadNode then scope.local(receiver.name)
2570
+ when Prism::InstanceVariableReadNode then scope.ivar(receiver.name)
2571
+ when Prism::SelfNode then scope.self_type
2572
+ end
2573
+ end
2574
+
2575
+ def narrow_receiver_binding(receiver, scope, narrowed)
2576
+ case receiver
2577
+ when Prism::LocalVariableReadNode then scope.with_local(receiver.name, narrowed)
2578
+ when Prism::InstanceVariableReadNode then scope.with_ivar(receiver.name, narrowed)
2579
+ when Prism::SelfNode then scope.with_self_type(narrowed)
2580
+ end
2581
+ end
2582
+
2355
2583
  # Narrows a safe-navigation call's receiver (`v&.foo`) to its non-nil fragment on the
2356
2584
  # truthy edge, returning `[truthy, falsey]` or nil when nothing applies (not safe-nav,
2357
2585
  # opaque receiver, or already non-nil). Used standalone for a bare `v&.foo` truthy edge
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "scope_indexer"
6
+ require_relative "fork_map"
6
7
  require_relative "../source/node_walker"
7
8
 
8
9
  module Rigor
@@ -120,15 +121,20 @@ module Rigor
120
121
  # @param target_ruby [String, nil] Prism parse target.
121
122
  # @param max_rounds [Integer] the WD5 fixpoint cap (1 = single-level).
122
123
  # @return [Hash{[String,Symbol,Symbol] => Hash{Symbol => Rigor::Type}}] frozen.
123
- def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
124
- new(files: files, environment: environment, target_ruby: target_ruby, max_rounds: max_rounds).collect
124
+ def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS, workers: 0)
125
+ new(files: files, environment: environment, target_ruby: target_ruby,
126
+ max_rounds: max_rounds, workers: workers).collect
125
127
  end
126
128
 
127
- def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
129
+ def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS, workers: 0)
128
130
  @files = files
129
131
  @environment = environment
130
132
  @target_ruby = target_ruby
131
133
  @max_rounds = max_rounds
134
+ # P3-10 — fork-parallelism for the per-round re-typing (the dominant coverage-protection cost). A
135
+ # round's per-file typing is independent; contributions merge associatively (see {#merge_round}), so
136
+ # forking over file slices is byte-identical to the sequential pass. 0/1 → sequential.
137
+ @workers = workers
132
138
  # Reset per round (see {#run_round}). `[[class, method, kind], param_sym]` => [Type] of
133
139
  # observed concrete arguments (a default-block Hash, not a `{}` literal, so the
134
140
  # analyzer types its reads generically — {#finalize}), plus the ids widened to
@@ -139,7 +145,16 @@ module Rigor
139
145
 
140
146
  def collect
141
147
  parsed = parse_all
148
+ # Force the full RBS load on the parent so forked round-workers copy-on-write inherit a warm
149
+ # environment instead of each rebuilding it (mirrors the check / scan fork pools). A no-op on the
150
+ # sequential path.
151
+ @environment.rbs_loader&.prewarm if ForkMap.parallel?([@workers, parsed.size].min)
142
152
  discovery = discovery_seed_tables
153
+ # The union of every method name any discovered `def` declares. A call whose name is absent cannot
154
+ # bind in `resolve_callee`, so {#record_call} short-circuits it before typing its receiver (an
155
+ # on-demand dispatch for an otherwise-unresolved call). ~73% of call sites in a Rails app name a
156
+ # method no user def declares; a pure negative filter, so the resolved table is byte-identical.
157
+ @user_method_names = user_method_names(discovery)
143
158
  table = EMPTY
144
159
  @max_rounds.times do
145
160
  rounded = run_round(parsed, discovery, table)
@@ -164,18 +179,51 @@ module Rigor
164
179
  end
165
180
 
166
181
  # One fixpoint round: re-type every file with `seed_table` (the previous round's inferred
167
- # parameters) seeded, collecting the next round's table.
182
+ # parameters) seeded, collecting the next round's table. The per-file typing is fork-mapped over
183
+ # `@workers` (byte-identical to sequential — see {#merge_round}); each slice returns a marshalable
184
+ # `[observations, poisoned]` contribution the parent merges in slice order.
168
185
  def run_round(parsed, discovery_tables, seed_table)
186
+ seed_scope = build_seed_scope(discovery_tables, seed_table)
187
+ contributions = ForkMap.call(items: parsed, workers: @workers) do |slice|
188
+ accumulate_slice(slice, seed_scope)
189
+ end
190
+ merge_round(contributions)
191
+ end
192
+
193
+ # Types every call in one contiguous file slice, returning a marshalable contribution: the per-`id`
194
+ # observed argument types (default proc stripped for `Marshal`) and the poisoned-`id` list. Uses the
195
+ # per-process observation ivars, so a forked worker and the sequential single-slice call share this
196
+ # exact code.
197
+ def accumulate_slice(parsed_slice, seed_scope)
169
198
  @type_observations = Hash.new { |hash, id| hash[id] = [] }
170
199
  @poisoned_params = Set.new
171
- seed_scope = build_seed_scope(discovery_tables, seed_table)
172
- parsed.each do |path, ast|
200
+ parsed_slice.each do |path, ast|
173
201
  index = ScopeIndexer.index(ast, default_scope: seed_scope.with_source_path(path))
174
202
  Source::NodeWalker.each(ast) do |node|
175
203
  record_call(node, index) if node.is_a?(Prism::CallNode)
176
204
  end
177
205
  end
178
- finalize
206
+ # Copy into a plain Hash, dropping the default proc — a Marshal-unfriendly proc the fork worker
207
+ # would otherwise fail to dump. (`.to_h` returns self here, keeping the proc, so copy explicitly.)
208
+ observations = @type_observations.each_with_object({}) { |(id, types), plain| plain[id] = types }
209
+ [observations, @poisoned_params.to_a]
210
+ end
211
+
212
+ # Merges the per-slice contributions into the round's table. Associative and order-preserving, so the
213
+ # result is identical to a sequential single-slice run: a parameter is poisoned if ANY slice poisoned
214
+ # it, its observations are the file-order concatenation across slices, and the {MAX_CALL_SITE_TYPES}
215
+ # cap re-applies over the merged total (a per-slice sub-cap can undercount, so the parent enforces the
216
+ # real cap — the same "poisoned" outcome the sequential mid-stream cap reaches).
217
+ def merge_round(contributions)
218
+ poisoned = contributions.each_with_object(Set.new) { |(_obs, pois), set| set.merge(pois) }
219
+ observations = {}
220
+ contributions.each do |(obs, _pois)|
221
+ obs.each do |id, types|
222
+ (observations[id] ||= []).concat(types) unless poisoned.include?(id)
223
+ end
224
+ end
225
+ observations.each { |id, types| poisoned << id if types.length > MAX_CALL_SITE_TYPES }
226
+ finalize(observations, poisoned)
179
227
  end
180
228
 
181
229
  # A scope carrying the cross-file discovery index (so `Foo.new` receivers and
@@ -221,7 +269,17 @@ module Rigor
221
269
  }.freeze
222
270
  private_constant :DISCOVERY_FIELD
223
271
 
272
+ # Built once over the whole project and inherited by the fork workers via copy-on-write; a frozen Set
273
+ # for O(1) membership in the {#record_call} hot loop.
274
+ def user_method_names(discovery)
275
+ tables = [discovery[:discovered_def_nodes], discovery[:discovered_singleton_def_nodes]].compact
276
+ tables.flat_map { |table| table.values.flat_map(&:keys) }.to_set.freeze
277
+ end
278
+
224
279
  def record_call(call_node, index)
280
+ # See {#collect}: a name no discovered def declares cannot resolve, so skip without typing anything.
281
+ return unless @user_method_names.include?(call_node.name)
282
+
225
283
  args = positional_args(call_node)
226
284
  return if args.nil?
227
285
 
@@ -343,13 +401,15 @@ module Rigor
343
401
  @type_observations.delete(id)
344
402
  end
345
403
 
346
- def finalize
404
+ # Builds the round's frozen `[class, method, kind] => {param => Type}` table from the merged
405
+ # observations and poisoned set.
406
+ def finalize(merged_observations, poisoned)
347
407
  # `result` is a default-block Hash (not a `{}` literal) so the analyzer types its reads
348
408
  # generically rather than folding the empty shape — the nesting writes stay plain
349
409
  # assignments, no literal-fold conditions.
350
410
  result = Hash.new { |hash, key| hash[key] = {} }
351
- @type_observations.each do |id, observations|
352
- next if @poisoned_params.include?(id)
411
+ merged_observations.each do |id, observations|
412
+ next if poisoned.include?(id)
353
413
  next if observations.empty?
354
414
 
355
415
  union = Type::Combinator.union(*observations)
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "project_patched_methods"
6
6
  require_relative "../analysis/dependency_source_inference/return_type_heuristic"
7
7
  require_relative "../source/constant_path"
8
+ require_relative "../source/node_children"
8
9
 
9
10
  module Rigor
10
11
  module Inference
@@ -141,7 +142,7 @@ module Rigor
141
142
  private_class_method :walk_node
142
143
 
143
144
  def walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
144
- node.compact_child_nodes.each do |child|
145
+ node.rigor_each_child do |child|
145
146
  walk_node(child, qualified_prefix, in_singleton_class, source_path, entries)
146
147
  end
147
148
  end
@@ -48,7 +48,14 @@ module Rigor
48
48
  RBS::Types::Bases::Self => :translate_self,
49
49
  RBS::Types::Bases::Instance => :translate_instance,
50
50
  RBS::Types::Bases::Class => :translate_untyped,
51
- RBS::Types::Bases::Void => :translate_untyped,
51
+ # `void` is the top type, not `untyped`: RBS defines the two as the same type ("They are all
52
+ # equivalent for the type system; they are all *top type*" — rbs `docs/syntax.md`), with `void`
53
+ # carrying only a hint that the value should not be used. Mapping it to `untyped` made Rigor looser
54
+ # than the toolchain it reads — `Dynamic[top]` is consistent with everything at a gradual boundary,
55
+ # where `top` demands proof — so a caller could silently depend on a return whose author declared
56
+ # "don't rely on this". See ADR-92 WD2; `special-types.md` § `void` (a distinct carrier + a value-use
57
+ # diagnostic) remains unimplemented and marked.
58
+ RBS::Types::Bases::Void => :translate_top,
52
59
  RBS::Types::Optional => :translate_optional,
53
60
  RBS::Types::Union => :translate_union,
54
61
  RBS::Types::Literal => :translate_literal,