rigortype 0.1.18 → 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 (89) 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 +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 +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -423,13 +423,45 @@ module Rigor
423
423
  # @param scope [Rigor::Scope]
424
424
  # @return [Array(Rigor::Scope, Rigor::Scope)]
425
425
  def case_when_scopes(subject, conditions, scope)
426
- 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)
427
435
 
428
436
  local_name = subject.name
429
437
  current = scope.local(local_name)
430
- return [scope, scope] if current.nil?
438
+ return [body_scope, scope] if current.nil?
439
+
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
431
444
 
432
- accumulate_case_when_scopes(scope, local_name, current, conditions)
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
433
465
  end
434
466
 
435
467
  # Internal analyser. Returns `[truthy_scope, falsey_scope]` when
@@ -936,11 +968,23 @@ module Rigor
936
968
 
937
969
  unless node.receiver.nil?
938
970
  shape_result = dispatch_call(node, scope, node.name)
939
- return shape_result if shape_result
971
+ return apply_safe_nav_non_nil(node, scope, shape_result) if shape_result
940
972
 
941
973
  # v0.1.1 Track 1 slice 4 — String predicate flow facts.
942
974
  string_predicate_result = analyse_string_predicate(node, scope)
943
- 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
944
988
  end
945
989
 
946
990
  # Slice 7 phase 15 — RBS::Extended predicate
@@ -1020,7 +1064,8 @@ module Rigor
1020
1064
  end
1021
1065
 
1022
1066
  def simple_dispatch_name?(name)
1023
- %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)
1024
1069
  end
1025
1070
 
1026
1071
  def dispatch_call_simple(node, scope, name)
@@ -1033,9 +1078,62 @@ module Rigor
1033
1078
  when :=~ then analyse_regex_match_predicate(node, scope)
1034
1079
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
1035
1080
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
1081
+ when :respond_to? then analyse_respond_to_predicate(node, scope)
1036
1082
  end
1037
1083
  end
1038
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
+
1039
1137
  # ADR-47 §4-4 (Elixir `tuple_size`/non-empty analogue) — a bare
1040
1138
  # `arr.empty?` / `arr.any?` / `arr.none?` (no block, no args)
1041
1139
  # narrows an Array-typed receiver to `non-empty-array[T]` on the
@@ -1230,8 +1328,8 @@ module Rigor
1230
1328
  regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
1231
1329
  return nil if regex_node.nil?
1232
1330
 
1233
- group_count = count_regex_capture_groups(regex_node.unescaped)
1234
- regex_match_predicate_scopes(scope, group_count)
1331
+ unconditional = unconditional_capture_groups(regex_node.unescaped)
1332
+ regex_match_predicate_scopes(scope, unconditional)
1235
1333
  end
1236
1334
 
1237
1335
  def regex_match_literal(left, right)
@@ -1247,7 +1345,15 @@ module Rigor
1247
1345
  REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
1248
1346
  private_constant :REGEX_MATCH_GLOBALS
1249
1347
 
1250
- 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)
1251
1357
  string_t = Type::Combinator.nominal_of("String")
1252
1358
  match_data_t = Type::Combinator.nominal_of("MatchData")
1253
1359
  nil_t = Type::Combinator.constant_of(nil)
@@ -1262,45 +1368,127 @@ module Rigor
1262
1368
  truthy = truthy.with_global(name, string_t)
1263
1369
  falsey = falsey.with_global(name, nil_t)
1264
1370
  end
1265
- group_count.times do |i|
1266
- name = :"$#{i + 1}"
1371
+ unconditional.each do |index|
1372
+ name = :"$#{index}"
1267
1373
  truthy = truthy.with_global(name, string_t)
1268
1374
  falsey = falsey.with_global(name, nil_t)
1269
1375
  end
1270
1376
  [truthy, falsey]
1271
1377
  end
1272
1378
 
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
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
1287
1407
  length = source.length
1288
- while i < length
1289
- c = source[i]
1290
- if c == "\\"
1291
- i += 2
1408
+ while pos < length
1409
+ chr = source[pos]
1410
+ if chr == "\\"
1411
+ pos += 2
1292
1412
  next
1293
1413
  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
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
1300
1436
  end
1301
- 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
1302
1490
  end
1303
- total
1491
+ pos
1304
1492
  end
1305
1493
 
1306
1494
  def dispatch_call_numeric(node, scope, name)
@@ -2379,6 +2567,51 @@ module Rigor
2379
2567
  end
2380
2568
  end
2381
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
+
2382
2615
  # `a && b` short-circuits: the truthy edge is the truthy edge
2383
2616
  # of `b` evaluated under `a`'s truthy scope; the falsey edge
2384
2617
  # is the union of `a`'s falsey scope (b skipped) and `b`'s