rigortype 0.1.18 → 0.2.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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -11,7 +11,8 @@ require_relative "../builtins/regex_refinement"
11
11
 
12
12
  module Rigor
13
13
  module Inference
14
- # Slice 6 phase 1 minimal narrowing surface.
14
+ # Control-flow predicate narrowing and type-lattice narrowing
15
+ # primitives.
15
16
  #
16
17
  # `Rigor::Inference::Narrowing` answers two related questions:
17
18
  #
@@ -19,22 +20,19 @@ module Rigor
19
20
  # truthy fragment, its falsey fragment, its nil fragment, and its
20
21
  # non-nil fragment? These primitives understand the value-lattice
21
22
  # algebra (`Constant`, `Nominal`, `Singleton`, `Tuple`, `HashShape`,
22
- # `Union`) and stay conservative on `Top` and `Dynamic[T]`, where
23
- # the analyzer cannot prove the boundary either way.
23
+ # `Union`) and stay conservative on `Top` and `Dynamic[T]`.
24
24
  # 2. Predicate-level narrowing: given a Prism predicate node and an
25
25
  # entry scope, what are the truthy-edge scope and the falsey-edge
26
- # scope after the predicate has been evaluated? The phase 1
27
- # catalogue covers truthiness on `LocalVariableReadNode`, `nil?`
28
- # against a local, the unary `!` inverter, parenthesised
29
- # predicates, and short-circuiting `&&` / `||` chains.
26
+ # scope? The catalogue covers truthiness, `nil?`, `!`, `&&`/`||`,
27
+ # class-membership (`is_a?`, `kind_of?`, `instance_of?`), trusted
28
+ # equality/inequality against static literals, `case`/`when`,
29
+ # regex match globals, string predicates (`start_with?` etc.),
30
+ # key-presence, array emptiness, numeric comparison, and
31
+ # `respond_to?`.
30
32
  #
31
- # Predicate-level narrowing is consumed by
32
- # `Rigor::Inference::StatementEvaluator` to refine the `then` and
33
- # `else` scopes of `IfNode`/`UnlessNode`. Phase 1 narrows local
34
- # bindings on truthiness and `nil?`; phase 2 extends the catalogue
35
- # with class-membership predicates (`is_a?`, `kind_of?`,
36
- # `instance_of?`) and trusted equality/inequality checks against
37
- # static literals.
33
+ # Consumed by `Rigor::Inference::StatementEvaluator` to refine
34
+ # `then`/`else` scopes of `IfNode`/`UnlessNode` and
35
+ # `case`/`when` branches.
38
36
  #
39
37
  # The module is pure: every public function returns fresh values and
40
38
  # MUST NOT mutate its inputs. Unrecognised predicate shapes degrade
@@ -43,8 +41,8 @@ module Rigor
43
41
  # `[truthy_scope, falsey_scope]` pair (the entry scope twice when no
44
42
  # rule matches).
45
43
  #
46
- # See docs/internal-spec/inference-engine.md (Slice 6 — Narrowing)
47
- # and docs/type-specification/control-flow-analysis.md for the
44
+ # See docs/internal-spec/inference-engine.md (Narrowing) and
45
+ # docs/type-specification/control-flow-analysis.md for the
48
46
  # binding contract.
49
47
  # rubocop:disable Metrics/ModuleLength
50
48
  module Narrowing
@@ -423,13 +421,45 @@ module Rigor
423
421
  # @param scope [Rigor::Scope]
424
422
  # @return [Array(Rigor::Scope, Rigor::Scope)]
425
423
  def case_when_scopes(subject, conditions, scope)
426
- return [scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
424
+ # C1 `case x when /re/` runs `/re/ === x`, which sets the
425
+ # regex match-data globals exactly as a successful `=~` does.
426
+ # Narrow `$~`/`$&`/`$1..$N` on the clause body (the match
427
+ # edge); the falsey scope keeps the entry globals because a
428
+ # later clause may match a different regex. Applied even when
429
+ # the subject is not a narrowable local read.
430
+ body_scope = apply_when_regex_globals(conditions, scope)
431
+
432
+ return [body_scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
427
433
 
428
434
  local_name = subject.name
429
435
  current = scope.local(local_name)
430
- return [scope, scope] if current.nil?
436
+ return [body_scope, scope] if current.nil?
437
+
438
+ truthy, = accumulate_case_when_scopes(body_scope, local_name, current, conditions)
439
+ _, falsey = accumulate_case_when_scopes(scope, local_name, current, conditions)
440
+ [truthy, falsey]
441
+ end
431
442
 
432
- accumulate_case_when_scopes(scope, local_name, current, conditions)
443
+ # When the clause has exactly one `RegularExpressionNode`
444
+ # literal condition, narrow the match-data globals on the body
445
+ # edge (same rule as `analyse_regex_match_predicate`'s truthy
446
+ # edge). With multiple regex conditions (`when /a/, /b/`) the
447
+ # body is reachable through any of them, so only `$~`/`$&` are
448
+ # safely non-nil; numbered groups whose presence differs per
449
+ # alternative stay `String | nil`. With no regex condition the
450
+ # entry scope passes through unchanged.
451
+ def apply_when_regex_globals(conditions, scope)
452
+ regexes = conditions.grep(Prism::RegularExpressionNode)
453
+ return scope if regexes.empty?
454
+
455
+ unconditional =
456
+ if regexes.size == 1
457
+ unconditional_capture_groups(regexes.first.unescaped)
458
+ else
459
+ Set.new
460
+ end
461
+ truthy, = regex_match_predicate_scopes(scope, unconditional)
462
+ truthy
433
463
  end
434
464
 
435
465
  # Internal analyser. Returns `[truthy_scope, falsey_scope]` when
@@ -936,11 +966,23 @@ module Rigor
936
966
 
937
967
  unless node.receiver.nil?
938
968
  shape_result = dispatch_call(node, scope, node.name)
939
- return shape_result if shape_result
969
+ return apply_safe_nav_non_nil(node, scope, shape_result) if shape_result
940
970
 
941
971
  # v0.1.1 Track 1 slice 4 — String predicate flow facts.
942
972
  string_predicate_result = analyse_string_predicate(node, scope)
943
- return string_predicate_result if string_predicate_result
973
+ return apply_safe_nav_non_nil(node, scope, string_predicate_result) if string_predicate_result
974
+
975
+ # A safe-navigation call (`v&.foo`) whose result is truthy
976
+ # proves the receiver was non-nil — `&.` returns `nil` when
977
+ # the receiver is nil, so a truthy outcome can only come from
978
+ # a non-nil receiver. Narrow the receiver on the truthy edge
979
+ # even when the call itself carries no other flow fact, so
980
+ # `v&.start_with?('[') && v.end_with?(']')` sees `v` non-nil
981
+ # in the `&&` right operand. The falsey edge is the
982
+ # conservative no-op (falsey could mean nil receiver OR a
983
+ # falsey method result).
984
+ safe_nav_result = analyse_safe_nav_receiver(node, scope)
985
+ return safe_nav_result if safe_nav_result
944
986
  end
945
987
 
946
988
  # Slice 7 phase 15 — RBS::Extended predicate
@@ -1020,7 +1062,8 @@ module Rigor
1020
1062
  end
1021
1063
 
1022
1064
  def simple_dispatch_name?(name)
1023
- %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?].include?(name)
1065
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?
1066
+ respond_to?].include?(name)
1024
1067
  end
1025
1068
 
1026
1069
  def dispatch_call_simple(node, scope, name)
@@ -1033,9 +1076,62 @@ module Rigor
1033
1076
  when :=~ then analyse_regex_match_predicate(node, scope)
1034
1077
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
1035
1078
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
1079
+ when :respond_to? then analyse_respond_to_predicate(node, scope)
1036
1080
  end
1037
1081
  end
1038
1082
 
1083
+ # T3 (template-corpora survey) — `recv.respond_to?(sym)` truthy
1084
+ # edge narrows `recv` non-nil. `nil.respond_to?(m)` is `false`
1085
+ # for every method `m` that `NilClass` does not define, so a
1086
+ # truthy `respond_to?` proves the receiver was not `nil` UNLESS
1087
+ # the queried symbol is one of `NilClass`'s own methods (`:to_s`,
1088
+ # `:inspect`, `:nil?`, …) — `nil` DOES respond to those, so the
1089
+ # truthy edge admits a nil receiver and we narrow nothing.
1090
+ #
1091
+ # Conservative floor: narrow only on a literal `Symbol`/`String`
1092
+ # argument resolved against the RBS environment; a non-literal
1093
+ # symbol, a missing argument, or a symbol that IS in `NilClass`'s
1094
+ # method set declines. The falsey edge is always the no-op
1095
+ # ("does not respond" proves little about the receiver's type).
1096
+ # Narrowing-only: it removes the `nil` constituent and never
1097
+ # promotes a non-nil type.
1098
+ def analyse_respond_to_predicate(node, scope)
1099
+ return nil if node.block
1100
+ return nil if node.arguments.nil? || node.arguments.arguments.size != 1
1101
+
1102
+ sym = static_hash_key(node.arguments.arguments.first)
1103
+ return nil if sym.nil?
1104
+ return nil if nilclass_method?(sym, scope)
1105
+
1106
+ reader, writer = emptiness_receiver_accessors(node.receiver)
1107
+ return nil if reader.nil?
1108
+
1109
+ current = scope.public_send(reader, node.receiver.name)
1110
+ return nil if current.nil?
1111
+
1112
+ non_nil = narrow_non_nil(current)
1113
+ return nil if non_nil.equal?(current)
1114
+
1115
+ [scope.public_send(writer, node.receiver.name, non_nil), scope]
1116
+ end
1117
+
1118
+ # True when `nil` responds to `sym` — i.e. `NilClass` (own,
1119
+ # inherited Kernel/BasicObject) defines an instance method named
1120
+ # `sym`. Resolved against the RBS environment; when the lookup
1121
+ # is unavailable the answer is conservatively `true` (decline to
1122
+ # narrow) so an unknown environment never manufactures a false
1123
+ # non-nil narrowing.
1124
+ def nilclass_method?(sym, scope)
1125
+ name = sym.respond_to?(:to_sym) ? sym.to_sym : sym
1126
+ return true unless name.is_a?(Symbol)
1127
+
1128
+ !Rigor::Reflection.instance_method_definition(
1129
+ "NilClass", name, environment: scope.environment
1130
+ ).nil?
1131
+ rescue StandardError
1132
+ true
1133
+ end
1134
+
1039
1135
  # ADR-47 §4-4 (Elixir `tuple_size`/non-empty analogue) — a bare
1040
1136
  # `arr.empty?` / `arr.any?` / `arr.none?` (no block, no args)
1041
1137
  # narrows an Array-typed receiver to `non-empty-array[T]` on the
@@ -1230,8 +1326,8 @@ module Rigor
1230
1326
  regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
1231
1327
  return nil if regex_node.nil?
1232
1328
 
1233
- group_count = count_regex_capture_groups(regex_node.unescaped)
1234
- regex_match_predicate_scopes(scope, group_count)
1329
+ unconditional = unconditional_capture_groups(regex_node.unescaped)
1330
+ regex_match_predicate_scopes(scope, unconditional)
1235
1331
  end
1236
1332
 
1237
1333
  def regex_match_literal(left, right)
@@ -1247,7 +1343,15 @@ module Rigor
1247
1343
  REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
1248
1344
  private_constant :REGEX_MATCH_GLOBALS
1249
1345
 
1250
- def regex_match_predicate_scopes(scope, group_count)
1346
+ # `unconditional` is the Set of 1-based numbered-capture
1347
+ # indices whose group is guaranteed to participate in any
1348
+ # successful match (no optional quantifier on the group or
1349
+ # an ancestor, no alternation in the pattern). Those `$N`
1350
+ # are bound to `String`; every other numbered group present
1351
+ # in the pattern stays `String | nil` on both edges (a
1352
+ # truthy match leaves an optional group nil at runtime), so
1353
+ # we do not narrow it on the truthy edge.
1354
+ def regex_match_predicate_scopes(scope, unconditional)
1251
1355
  string_t = Type::Combinator.nominal_of("String")
1252
1356
  match_data_t = Type::Combinator.nominal_of("MatchData")
1253
1357
  nil_t = Type::Combinator.constant_of(nil)
@@ -1262,45 +1366,127 @@ module Rigor
1262
1366
  truthy = truthy.with_global(name, string_t)
1263
1367
  falsey = falsey.with_global(name, nil_t)
1264
1368
  end
1265
- group_count.times do |i|
1266
- name = :"$#{i + 1}"
1369
+ unconditional.each do |index|
1370
+ name = :"$#{index}"
1267
1371
  truthy = truthy.with_global(name, string_t)
1268
1372
  falsey = falsey.with_global(name, nil_t)
1269
1373
  end
1270
1374
  [truthy, falsey]
1271
1375
  end
1272
1376
 
1273
- # Counts capture groups (numbered + named both
1274
- # contribute to `$1..$N`) in a regex source. Backslash
1275
- # escapes are skipped; non-capturing `(?:...)`, lookahead
1276
- # `(?=...)` / `(?!...)`, and lookbehind `(?<=...)` /
1277
- # `(?<!...)` do NOT count. Named groups `(?<name>...)`
1278
- # DO count. The walker is intentionally light — it does
1279
- # not parse the regex AST, just scans char-by-char so
1280
- # exotic constructs that overlap the lookaround syntax
1281
- # may miscount; the unsoundness is bounded (over- or
1282
- # under-binding a few `$N` globals) and we already accept
1283
- # the same shape of unsoundness for `analyse_match_write`.
1284
- def count_regex_capture_groups(source)
1285
- i = 0
1286
- total = 0
1377
+ # Returns the Set of 1-based numbered-capture indices that
1378
+ # are UNCONDITIONAL in `source`: present on every successful
1379
+ # match because no optional quantifier (`?`, `*`, `{0,…}`)
1380
+ # applies to the group or any ancestor group, and the
1381
+ # pattern contains no alternation (`|`). Optional and
1382
+ # alternation-reachable groups are excluded at runtime `$N`
1383
+ # is `nil` for them even when the overall match succeeds, so
1384
+ # narrowing them to non-nil `String` would be unsound. The
1385
+ # walker is intentionally light (char scan, not a regex-AST
1386
+ # parse): backslash escapes are skipped; `(?:…)`, lookahead
1387
+ # `(?=…)`/`(?!…)`, and lookbehind `(?<=…)`/`(?<!…)` do not
1388
+ # capture; named groups `(?<name>…)` do. Conservatism is
1389
+ # one-directional — when in doubt a group is treated as
1390
+ # conditional (dropped from the Set), never the reverse.
1391
+ def unconditional_capture_groups(source)
1392
+ # `unconditional` collects every capturing index; a group is
1393
+ # later removed (with its whole subtree) when it is optionally
1394
+ # quantified, nested under an optional ancestor, or sits in an
1395
+ # alternation branch. `stack` holds one frame per open group
1396
+ # (plus a virtual root frame for the top level) as
1397
+ # `[group_index_or_nil, descendant_indices, alternated?]`.
1398
+ # A `|` marks the CURRENT group's frame alternated — its
1399
+ # branches are mutually exclusive, so its descendant captures
1400
+ # may be absent on a successful match; the group itself still
1401
+ # participates. Closing a frame rolls its subtree up to the
1402
+ # parent so an optional / alternated ancestor disqualifies it.
1403
+ state = { unconditional: Set.new, stack: [[nil, [], false]], group_index: 0 }
1404
+ pos = 0
1287
1405
  length = source.length
1288
- while i < length
1289
- c = source[i]
1290
- if c == "\\"
1291
- i += 2
1406
+ while pos < length
1407
+ chr = source[pos]
1408
+ if chr == "\\"
1409
+ pos += 2
1292
1410
  next
1293
1411
  end
1294
- if c == "("
1295
- if source[i + 1] == "?"
1296
- total += 1 if source[i + 2] == "<" && source[i + 3] != "=" && source[i + 3] != "!"
1297
- else
1298
- total += 1
1299
- end
1412
+ if chr == "["
1413
+ pos = skip_char_class(source, pos) + 1
1414
+ next
1415
+ end
1416
+ scan_group_char(source, pos, chr, state)
1417
+ pos += 1
1418
+ end
1419
+ # Drain the virtual root: a top-level `|` disqualifies all.
1420
+ finalize_frame(state, state[:stack].pop, optional: false)
1421
+ state[:unconditional]
1422
+ end
1423
+
1424
+ # Updates the walk `state` at a group-relevant char during
1425
+ # {#unconditional_capture_groups}: `(` pushes a frame, `)` pops
1426
+ # and resolves it, `|` flags the current frame as alternated.
1427
+ def scan_group_char(source, pos, chr, state)
1428
+ case chr
1429
+ when "("
1430
+ idx = nil
1431
+ if capturing_group?(source, pos)
1432
+ idx = (state[:group_index] += 1)
1433
+ state[:unconditional] << idx
1300
1434
  end
1301
- i += 1
1435
+ state[:stack].push([idx, [], false])
1436
+ when ")"
1437
+ finalize_frame(state, state[:stack].pop, optional: next_quantifier_optional?(source, pos + 1))
1438
+ when "|"
1439
+ state[:stack].last[2] = true
1440
+ end
1441
+ end
1442
+
1443
+ # Resolves a closed (or virtual-root) group frame: its subtree is
1444
+ # its own index plus every descendant index. The subtree is
1445
+ # disqualified when the group is optionally quantified or its
1446
+ # branches are alternated (only the descendants in that case —
1447
+ # but a self subtree always keeps its own index unless optional).
1448
+ def finalize_frame(state, frame, optional:)
1449
+ idx, descendants, alternated = frame
1450
+ subtree = descendants.dup
1451
+ subtree << idx if idx
1452
+ state[:unconditional].subtract(subtree) if optional
1453
+ # Alternation disqualifies the branch contents (descendants),
1454
+ # never the enclosing group itself.
1455
+ state[:unconditional].subtract(descendants) if alternated
1456
+ state[:stack].last && state[:stack].last[1].concat(subtree)
1457
+ end
1458
+
1459
+ # True when the group whose closing paren is at `source[pos]`
1460
+ # is followed by a quantifier that permits zero repetitions
1461
+ # (`?`, `*`, `{0…}`). `+` and `{1,…}` do NOT make a group
1462
+ # optional. A lazy/possessive suffix (`*?`, `*+`) is still
1463
+ # zero-permitting on the base quantifier.
1464
+ def next_quantifier_optional?(source, pos)
1465
+ case source[pos]
1466
+ when "?", "*" then true
1467
+ when "{" then source[pos + 1] == "0"
1468
+ else false
1469
+ end
1470
+ end
1471
+
1472
+ def capturing_group?(source, pos)
1473
+ return true unless source[pos + 1] == "?"
1474
+
1475
+ # `(?<name>…)` captures; `(?<=…)`/`(?<!…)` (lookbehind) and
1476
+ # `(?:…)`/`(?=…)`/`(?!…)` do not.
1477
+ source[pos + 2] == "<" && source[pos + 3] != "=" && source[pos + 3] != "!"
1478
+ end
1479
+
1480
+ def skip_char_class(source, start)
1481
+ pos = start + 1
1482
+ pos += 1 if source[pos] == "^"
1483
+ pos += 1 if source[pos] == "]" # literal ] as first member
1484
+ while pos < source.length
1485
+ return pos if source[pos] == "]"
1486
+
1487
+ pos += source[pos] == "\\" ? 2 : 1
1302
1488
  end
1303
- total
1489
+ pos
1304
1490
  end
1305
1491
 
1306
1492
  def dispatch_call_numeric(node, scope, name)
@@ -2379,6 +2565,51 @@ module Rigor
2379
2565
  end
2380
2566
  end
2381
2567
 
2568
+ # Narrows a safe-navigation call's receiver (`v&.foo`) to its
2569
+ # non-nil fragment on the truthy edge, returning `[truthy, falsey]`
2570
+ # or nil when nothing applies (not safe-nav, opaque receiver, or
2571
+ # already non-nil). Used standalone for a bare `v&.foo` truthy
2572
+ # edge and as a post-pass over the existing predicate edges.
2573
+ def analyse_safe_nav_receiver(node, scope)
2574
+ return nil unless node.safe_navigation?
2575
+
2576
+ receiver = node.receiver
2577
+ reader, writer =
2578
+ case receiver
2579
+ when Prism::LocalVariableReadNode then %i[local with_local]
2580
+ when Prism::InstanceVariableReadNode then %i[ivar with_ivar]
2581
+ else return nil
2582
+ end
2583
+
2584
+ current = scope.public_send(reader, receiver.name)
2585
+ return nil if current.nil?
2586
+
2587
+ non_nil = narrow_non_nil(current)
2588
+ return nil if non_nil.equal?(current)
2589
+
2590
+ [scope.public_send(writer, receiver.name, non_nil), scope]
2591
+ end
2592
+
2593
+ # Layers the safe-nav non-nil truthy narrowing over the edges an
2594
+ # existing predicate path already produced, so a safe-nav string
2595
+ # predicate (`v&.start_with?(x)`) keeps its relational fact AND
2596
+ # proves `v` non-nil on the truthy edge. Re-runs the predicate's
2597
+ # narrowing under the non-nil truthy scope so the fact is attached
2598
+ # to the narrowed binding. No-op for non-safe-nav calls.
2599
+ def apply_safe_nav_non_nil(node, scope, edges)
2600
+ return edges unless node.safe_navigation? && edges
2601
+
2602
+ truthy, falsey = edges
2603
+ safe = analyse_safe_nav_receiver(node, scope)
2604
+ return edges unless safe
2605
+
2606
+ receiver = node.receiver
2607
+ reader = receiver.is_a?(Prism::InstanceVariableReadNode) ? :ivar : :local
2608
+ non_nil = safe.first.public_send(reader, receiver.name)
2609
+ writer = receiver.is_a?(Prism::InstanceVariableReadNode) ? :with_ivar : :with_local
2610
+ [truthy.public_send(writer, receiver.name, non_nil), falsey]
2611
+ end
2612
+
2382
2613
  # `a && b` short-circuits: the truthy edge is the truthy edge
2383
2614
  # of `b` evaluated under `a`'s truthy scope; the falsey edge
2384
2615
  # is the union of `a`'s falsey scope (b skipped) and `b`'s