rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -123,6 +123,66 @@ module Rigor
123
123
  end
124
124
  end
125
125
 
126
+ # Three-valued truthiness certainty of a predicate's type,
127
+ # derived from the truthy / falsey fragments above: `:truthy`
128
+ # when no inhabitant is falsey (the falsey fragment is `Bot`),
129
+ # `:falsey` when no inhabitant is truthy, nil when both
130
+ # fragments are inhabited — or when the type itself is nil /
131
+ # `Bot` (dead code is not a certainty claim). This is the single
132
+ # owner of the judgment both branch-elision consumers read
133
+ # (`ExpressionTyper#elide_or_union` on the value side,
134
+ # `StatementEvaluator#live_branch_for_if` on the scope side), so
135
+ # the type a dead branch is elided from and the scope that stops
136
+ # flowing through it can never disagree.
137
+ def predicate_certainty(type)
138
+ return nil if type.nil? || type.is_a?(Type::Bot)
139
+
140
+ truthy_bot = narrow_truthy(type).is_a?(Type::Bot)
141
+ falsey_bot = narrow_falsey(type).is_a?(Type::Bot)
142
+
143
+ return :falsey if truthy_bot && !falsey_bot
144
+ return :truthy if !truthy_bot && falsey_bot
145
+
146
+ nil
147
+ end
148
+
149
+ # Three-valued certainty of `C === subject` for a class / module
150
+ # `when` pattern, derived from {.narrow_class} /
151
+ # {.narrow_not_class}: `:no` when no inhabitant of the subject
152
+ # matches, `:yes` when every inhabitant matches, `:maybe`
153
+ # otherwise. The value-side counterpart of the scope narrowing
154
+ # {.case_when_scopes} performs for the same condition shape, kept
155
+ # here so the branch a `case` expression's type drops and the
156
+ # clause whose body scope goes dead derive from one judgment.
157
+ def class_pattern_certainty(subject_type, class_name, environment:)
158
+ truthy_bot = narrow_class(subject_type, class_name, environment: environment).is_a?(Type::Bot)
159
+ falsey_bot = narrow_not_class(subject_type, class_name, environment: environment).is_a?(Type::Bot)
160
+
161
+ return :no if truthy_bot && !falsey_bot
162
+ return :yes if !truthy_bot && falsey_bot
163
+
164
+ :maybe
165
+ end
166
+
167
+ # Classes whose `===` is plain value equality, so a literal
168
+ # `when` pattern against a pinned `Constant` subject is exact in
169
+ # both directions. Anything else keeps custom-`===` semantics
170
+ # and stays `:maybe` in {.value_pattern_certainty}.
171
+ VALUE_EQUALITY_CLASSES = [Integer, Float, Rational, Complex, String, Symbol,
172
+ TrueClass, FalseClass, NilClass].freeze
173
+
174
+ # Three-valued certainty of `<literal> === subject` for a
175
+ # value-equality literal pattern: exact (`:yes` / `:no`) only
176
+ # when the subject is itself a pinned `Constant` of a
177
+ # value-equality class; `:maybe` otherwise (the runtime value
178
+ # isn't pinned, or `===` may be user-defined).
179
+ def value_pattern_certainty(subject_type, pattern_value)
180
+ return :maybe unless subject_type.is_a?(Type::Constant)
181
+ return :maybe unless VALUE_EQUALITY_CLASSES.any? { |klass| subject_type.value.is_a?(klass) }
182
+
183
+ pattern_value == subject_type.value ? :yes : :no
184
+ end
185
+
126
186
  # Equality fragment of `type` against a trusted literal.
127
187
  #
128
188
  # String/Symbol/Integer equality narrows only when the current
@@ -363,13 +423,45 @@ module Rigor
363
423
  # @param scope [Rigor::Scope]
364
424
  # @return [Array(Rigor::Scope, Rigor::Scope)]
365
425
  def case_when_scopes(subject, conditions, scope)
366
- return [scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
426
+ # C1 `case x when /re/` runs `/re/ === x`, which sets the
427
+ # regex match-data globals exactly as a successful `=~` does.
428
+ # Narrow `$~`/`$&`/`$1..$N` on the clause body (the match
429
+ # edge); the falsey scope keeps the entry globals because a
430
+ # later clause may match a different regex. Applied even when
431
+ # the subject is not a narrowable local read.
432
+ body_scope = apply_when_regex_globals(conditions, scope)
433
+
434
+ return [body_scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
367
435
 
368
436
  local_name = subject.name
369
437
  current = scope.local(local_name)
370
- return [scope, scope] if current.nil?
438
+ return [body_scope, scope] if current.nil?
371
439
 
372
- accumulate_case_when_scopes(scope, local_name, current, conditions)
440
+ truthy, = accumulate_case_when_scopes(body_scope, local_name, current, conditions)
441
+ _, falsey = accumulate_case_when_scopes(scope, local_name, current, conditions)
442
+ [truthy, falsey]
443
+ end
444
+
445
+ # When the clause has exactly one `RegularExpressionNode`
446
+ # literal condition, narrow the match-data globals on the body
447
+ # edge (same rule as `analyse_regex_match_predicate`'s truthy
448
+ # edge). With multiple regex conditions (`when /a/, /b/`) the
449
+ # body is reachable through any of them, so only `$~`/`$&` are
450
+ # safely non-nil; numbered groups whose presence differs per
451
+ # alternative stay `String | nil`. With no regex condition the
452
+ # entry scope passes through unchanged.
453
+ def apply_when_regex_globals(conditions, scope)
454
+ regexes = conditions.grep(Prism::RegularExpressionNode)
455
+ return scope if regexes.empty?
456
+
457
+ unconditional =
458
+ if regexes.size == 1
459
+ unconditional_capture_groups(regexes.first.unescaped)
460
+ else
461
+ Set.new
462
+ end
463
+ truthy, = regex_match_predicate_scopes(scope, unconditional)
464
+ truthy
373
465
  end
374
466
 
375
467
  # Internal analyser. Returns `[truthy_scope, falsey_scope]` when
@@ -876,11 +968,23 @@ module Rigor
876
968
 
877
969
  unless node.receiver.nil?
878
970
  shape_result = dispatch_call(node, scope, node.name)
879
- return shape_result if shape_result
971
+ return apply_safe_nav_non_nil(node, scope, shape_result) if shape_result
880
972
 
881
973
  # v0.1.1 Track 1 slice 4 — String predicate flow facts.
882
974
  string_predicate_result = analyse_string_predicate(node, scope)
883
- return string_predicate_result if string_predicate_result
975
+ return apply_safe_nav_non_nil(node, scope, string_predicate_result) if string_predicate_result
976
+
977
+ # A safe-navigation call (`v&.foo`) whose result is truthy
978
+ # proves the receiver was non-nil — `&.` returns `nil` when
979
+ # the receiver is nil, so a truthy outcome can only come from
980
+ # a non-nil receiver. Narrow the receiver on the truthy edge
981
+ # even when the call itself carries no other flow fact, so
982
+ # `v&.start_with?('[') && v.end_with?(']')` sees `v` non-nil
983
+ # in the `&&` right operand. The falsey edge is the
984
+ # conservative no-op (falsey could mean nil receiver OR a
985
+ # falsey method result).
986
+ safe_nav_result = analyse_safe_nav_receiver(node, scope)
987
+ return safe_nav_result if safe_nav_result
884
988
  end
885
989
 
886
990
  # Slice 7 phase 15 — RBS::Extended predicate
@@ -960,7 +1064,8 @@ module Rigor
960
1064
  end
961
1065
 
962
1066
  def simple_dispatch_name?(name)
963
- %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?].include?(name)
1067
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?
1068
+ respond_to?].include?(name)
964
1069
  end
965
1070
 
966
1071
  def dispatch_call_simple(node, scope, name)
@@ -973,9 +1078,62 @@ module Rigor
973
1078
  when :=~ then analyse_regex_match_predicate(node, scope)
974
1079
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
975
1080
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
1081
+ when :respond_to? then analyse_respond_to_predicate(node, scope)
976
1082
  end
977
1083
  end
978
1084
 
1085
+ # T3 (template-corpora survey) — `recv.respond_to?(sym)` truthy
1086
+ # edge narrows `recv` non-nil. `nil.respond_to?(m)` is `false`
1087
+ # for every method `m` that `NilClass` does not define, so a
1088
+ # truthy `respond_to?` proves the receiver was not `nil` UNLESS
1089
+ # the queried symbol is one of `NilClass`'s own methods (`:to_s`,
1090
+ # `:inspect`, `:nil?`, …) — `nil` DOES respond to those, so the
1091
+ # truthy edge admits a nil receiver and we narrow nothing.
1092
+ #
1093
+ # Conservative floor: narrow only on a literal `Symbol`/`String`
1094
+ # argument resolved against the RBS environment; a non-literal
1095
+ # symbol, a missing argument, or a symbol that IS in `NilClass`'s
1096
+ # method set declines. The falsey edge is always the no-op
1097
+ # ("does not respond" proves little about the receiver's type).
1098
+ # Narrowing-only: it removes the `nil` constituent and never
1099
+ # promotes a non-nil type.
1100
+ def analyse_respond_to_predicate(node, scope)
1101
+ return nil if node.block
1102
+ return nil if node.arguments.nil? || node.arguments.arguments.size != 1
1103
+
1104
+ sym = static_hash_key(node.arguments.arguments.first)
1105
+ return nil if sym.nil?
1106
+ return nil if nilclass_method?(sym, scope)
1107
+
1108
+ reader, writer = emptiness_receiver_accessors(node.receiver)
1109
+ return nil if reader.nil?
1110
+
1111
+ current = scope.public_send(reader, node.receiver.name)
1112
+ return nil if current.nil?
1113
+
1114
+ non_nil = narrow_non_nil(current)
1115
+ return nil if non_nil.equal?(current)
1116
+
1117
+ [scope.public_send(writer, node.receiver.name, non_nil), scope]
1118
+ end
1119
+
1120
+ # True when `nil` responds to `sym` — i.e. `NilClass` (own,
1121
+ # inherited Kernel/BasicObject) defines an instance method named
1122
+ # `sym`. Resolved against the RBS environment; when the lookup
1123
+ # is unavailable the answer is conservatively `true` (decline to
1124
+ # narrow) so an unknown environment never manufactures a false
1125
+ # non-nil narrowing.
1126
+ def nilclass_method?(sym, scope)
1127
+ name = sym.respond_to?(:to_sym) ? sym.to_sym : sym
1128
+ return true unless name.is_a?(Symbol)
1129
+
1130
+ !Rigor::Reflection.instance_method_definition(
1131
+ "NilClass", name, environment: scope.environment
1132
+ ).nil?
1133
+ rescue StandardError
1134
+ true
1135
+ end
1136
+
979
1137
  # ADR-47 §4-4 (Elixir `tuple_size`/non-empty analogue) — a bare
980
1138
  # `arr.empty?` / `arr.any?` / `arr.none?` (no block, no args)
981
1139
  # narrows an Array-typed receiver to `non-empty-array[T]` on the
@@ -1170,8 +1328,8 @@ module Rigor
1170
1328
  regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
1171
1329
  return nil if regex_node.nil?
1172
1330
 
1173
- group_count = count_regex_capture_groups(regex_node.unescaped)
1174
- regex_match_predicate_scopes(scope, group_count)
1331
+ unconditional = unconditional_capture_groups(regex_node.unescaped)
1332
+ regex_match_predicate_scopes(scope, unconditional)
1175
1333
  end
1176
1334
 
1177
1335
  def regex_match_literal(left, right)
@@ -1187,7 +1345,15 @@ module Rigor
1187
1345
  REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
1188
1346
  private_constant :REGEX_MATCH_GLOBALS
1189
1347
 
1190
- def regex_match_predicate_scopes(scope, group_count)
1348
+ # `unconditional` is the Set of 1-based numbered-capture
1349
+ # indices whose group is guaranteed to participate in any
1350
+ # successful match (no optional quantifier on the group or
1351
+ # an ancestor, no alternation in the pattern). Those `$N`
1352
+ # are bound to `String`; every other numbered group present
1353
+ # in the pattern stays `String | nil` on both edges (a
1354
+ # truthy match leaves an optional group nil at runtime), so
1355
+ # we do not narrow it on the truthy edge.
1356
+ def regex_match_predicate_scopes(scope, unconditional)
1191
1357
  string_t = Type::Combinator.nominal_of("String")
1192
1358
  match_data_t = Type::Combinator.nominal_of("MatchData")
1193
1359
  nil_t = Type::Combinator.constant_of(nil)
@@ -1202,45 +1368,127 @@ module Rigor
1202
1368
  truthy = truthy.with_global(name, string_t)
1203
1369
  falsey = falsey.with_global(name, nil_t)
1204
1370
  end
1205
- group_count.times do |i|
1206
- name = :"$#{i + 1}"
1371
+ unconditional.each do |index|
1372
+ name = :"$#{index}"
1207
1373
  truthy = truthy.with_global(name, string_t)
1208
1374
  falsey = falsey.with_global(name, nil_t)
1209
1375
  end
1210
1376
  [truthy, falsey]
1211
1377
  end
1212
1378
 
1213
- # Counts capture groups (numbered + named both
1214
- # contribute to `$1..$N`) in a regex source. Backslash
1215
- # escapes are skipped; non-capturing `(?:...)`, lookahead
1216
- # `(?=...)` / `(?!...)`, and lookbehind `(?<=...)` /
1217
- # `(?<!...)` do NOT count. Named groups `(?<name>...)`
1218
- # DO count. The walker is intentionally light — it does
1219
- # not parse the regex AST, just scans char-by-char so
1220
- # exotic constructs that overlap the lookaround syntax
1221
- # may miscount; the unsoundness is bounded (over- or
1222
- # under-binding a few `$N` globals) and we already accept
1223
- # the same shape of unsoundness for `analyse_match_write`.
1224
- def count_regex_capture_groups(source)
1225
- i = 0
1226
- total = 0
1379
+ # Returns the Set of 1-based numbered-capture indices that
1380
+ # are UNCONDITIONAL in `source`: present on every successful
1381
+ # match because no optional quantifier (`?`, `*`, `{0,…}`)
1382
+ # applies to the group or any ancestor group, and the
1383
+ # pattern contains no alternation (`|`). Optional and
1384
+ # alternation-reachable groups are excluded at runtime `$N`
1385
+ # is `nil` for them even when the overall match succeeds, so
1386
+ # narrowing them to non-nil `String` would be unsound. The
1387
+ # walker is intentionally light (char scan, not a regex-AST
1388
+ # parse): backslash escapes are skipped; `(?:…)`, lookahead
1389
+ # `(?=…)`/`(?!…)`, and lookbehind `(?<=…)`/`(?<!…)` do not
1390
+ # capture; named groups `(?<name>…)` do. Conservatism is
1391
+ # one-directional — when in doubt a group is treated as
1392
+ # conditional (dropped from the Set), never the reverse.
1393
+ def unconditional_capture_groups(source)
1394
+ # `unconditional` collects every capturing index; a group is
1395
+ # later removed (with its whole subtree) when it is optionally
1396
+ # quantified, nested under an optional ancestor, or sits in an
1397
+ # alternation branch. `stack` holds one frame per open group
1398
+ # (plus a virtual root frame for the top level) as
1399
+ # `[group_index_or_nil, descendant_indices, alternated?]`.
1400
+ # A `|` marks the CURRENT group's frame alternated — its
1401
+ # branches are mutually exclusive, so its descendant captures
1402
+ # may be absent on a successful match; the group itself still
1403
+ # participates. Closing a frame rolls its subtree up to the
1404
+ # parent so an optional / alternated ancestor disqualifies it.
1405
+ state = { unconditional: Set.new, stack: [[nil, [], false]], group_index: 0 }
1406
+ pos = 0
1227
1407
  length = source.length
1228
- while i < length
1229
- c = source[i]
1230
- if c == "\\"
1231
- i += 2
1408
+ while pos < length
1409
+ chr = source[pos]
1410
+ if chr == "\\"
1411
+ pos += 2
1232
1412
  next
1233
1413
  end
1234
- if c == "("
1235
- if source[i + 1] == "?"
1236
- total += 1 if source[i + 2] == "<" && source[i + 3] != "=" && source[i + 3] != "!"
1237
- else
1238
- total += 1
1239
- end
1414
+ if chr == "["
1415
+ pos = skip_char_class(source, pos) + 1
1416
+ next
1417
+ end
1418
+ scan_group_char(source, pos, chr, state)
1419
+ pos += 1
1420
+ end
1421
+ # Drain the virtual root: a top-level `|` disqualifies all.
1422
+ finalize_frame(state, state[:stack].pop, optional: false)
1423
+ state[:unconditional]
1424
+ end
1425
+
1426
+ # Updates the walk `state` at a group-relevant char during
1427
+ # {#unconditional_capture_groups}: `(` pushes a frame, `)` pops
1428
+ # and resolves it, `|` flags the current frame as alternated.
1429
+ def scan_group_char(source, pos, chr, state)
1430
+ case chr
1431
+ when "("
1432
+ idx = nil
1433
+ if capturing_group?(source, pos)
1434
+ idx = (state[:group_index] += 1)
1435
+ state[:unconditional] << idx
1240
1436
  end
1241
- i += 1
1437
+ state[:stack].push([idx, [], false])
1438
+ when ")"
1439
+ finalize_frame(state, state[:stack].pop, optional: next_quantifier_optional?(source, pos + 1))
1440
+ when "|"
1441
+ state[:stack].last[2] = true
1442
+ end
1443
+ end
1444
+
1445
+ # Resolves a closed (or virtual-root) group frame: its subtree is
1446
+ # its own index plus every descendant index. The subtree is
1447
+ # disqualified when the group is optionally quantified or its
1448
+ # branches are alternated (only the descendants in that case —
1449
+ # but a self subtree always keeps its own index unless optional).
1450
+ def finalize_frame(state, frame, optional:)
1451
+ idx, descendants, alternated = frame
1452
+ subtree = descendants.dup
1453
+ subtree << idx if idx
1454
+ state[:unconditional].subtract(subtree) if optional
1455
+ # Alternation disqualifies the branch contents (descendants),
1456
+ # never the enclosing group itself.
1457
+ state[:unconditional].subtract(descendants) if alternated
1458
+ state[:stack].last && state[:stack].last[1].concat(subtree)
1459
+ end
1460
+
1461
+ # True when the group whose closing paren is at `source[pos]`
1462
+ # is followed by a quantifier that permits zero repetitions
1463
+ # (`?`, `*`, `{0…}`). `+` and `{1,…}` do NOT make a group
1464
+ # optional. A lazy/possessive suffix (`*?`, `*+`) is still
1465
+ # zero-permitting on the base quantifier.
1466
+ def next_quantifier_optional?(source, pos)
1467
+ case source[pos]
1468
+ when "?", "*" then true
1469
+ when "{" then source[pos + 1] == "0"
1470
+ else false
1471
+ end
1472
+ end
1473
+
1474
+ def capturing_group?(source, pos)
1475
+ return true unless source[pos + 1] == "?"
1476
+
1477
+ # `(?<name>…)` captures; `(?<=…)`/`(?<!…)` (lookbehind) and
1478
+ # `(?:…)`/`(?=…)`/`(?!…)` do not.
1479
+ source[pos + 2] == "<" && source[pos + 3] != "=" && source[pos + 3] != "!"
1480
+ end
1481
+
1482
+ def skip_char_class(source, start)
1483
+ pos = start + 1
1484
+ pos += 1 if source[pos] == "^"
1485
+ pos += 1 if source[pos] == "]" # literal ] as first member
1486
+ while pos < source.length
1487
+ return pos if source[pos] == "]"
1488
+
1489
+ pos += source[pos] == "\\" ? 2 : 1
1242
1490
  end
1243
- total
1491
+ pos
1244
1492
  end
1245
1493
 
1246
1494
  def dispatch_call_numeric(node, scope, name)
@@ -2319,6 +2567,51 @@ module Rigor
2319
2567
  end
2320
2568
  end
2321
2569
 
2570
+ # Narrows a safe-navigation call's receiver (`v&.foo`) to its
2571
+ # non-nil fragment on the truthy edge, returning `[truthy, falsey]`
2572
+ # or nil when nothing applies (not safe-nav, opaque receiver, or
2573
+ # already non-nil). Used standalone for a bare `v&.foo` truthy
2574
+ # edge and as a post-pass over the existing predicate edges.
2575
+ def analyse_safe_nav_receiver(node, scope)
2576
+ return nil unless node.safe_navigation?
2577
+
2578
+ receiver = node.receiver
2579
+ reader, writer =
2580
+ case receiver
2581
+ when Prism::LocalVariableReadNode then %i[local with_local]
2582
+ when Prism::InstanceVariableReadNode then %i[ivar with_ivar]
2583
+ else return nil
2584
+ end
2585
+
2586
+ current = scope.public_send(reader, receiver.name)
2587
+ return nil if current.nil?
2588
+
2589
+ non_nil = narrow_non_nil(current)
2590
+ return nil if non_nil.equal?(current)
2591
+
2592
+ [scope.public_send(writer, receiver.name, non_nil), scope]
2593
+ end
2594
+
2595
+ # Layers the safe-nav non-nil truthy narrowing over the edges an
2596
+ # existing predicate path already produced, so a safe-nav string
2597
+ # predicate (`v&.start_with?(x)`) keeps its relational fact AND
2598
+ # proves `v` non-nil on the truthy edge. Re-runs the predicate's
2599
+ # narrowing under the non-nil truthy scope so the fact is attached
2600
+ # to the narrowed binding. No-op for non-safe-nav calls.
2601
+ def apply_safe_nav_non_nil(node, scope, edges)
2602
+ return edges unless node.safe_navigation? && edges
2603
+
2604
+ truthy, falsey = edges
2605
+ safe = analyse_safe_nav_receiver(node, scope)
2606
+ return edges unless safe
2607
+
2608
+ receiver = node.receiver
2609
+ reader = receiver.is_a?(Prism::InstanceVariableReadNode) ? :ivar : :local
2610
+ non_nil = safe.first.public_send(reader, receiver.name)
2611
+ writer = receiver.is_a?(Prism::InstanceVariableReadNode) ? :with_ivar : :with_local
2612
+ [truthy.public_send(writer, receiver.name, non_nil), falsey]
2613
+ end
2614
+
2322
2615
  # `a && b` short-circuits: the truthy edge is the truthy edge
2323
2616
  # of `b` evaluated under `a`'s truthy scope; the falsey edge
2324
2617
  # is the union of `a`'s falsey scope (b skipped) and `b`'s