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.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +72 -1
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +7 -2
- 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
|
-
|
|
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 [
|
|
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
|
-
|
|
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?
|
|
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
|
-
|
|
1234
|
-
regex_match_predicate_scopes(scope,
|
|
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
|
-
|
|
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
|
-
|
|
1266
|
-
name = :"$#{
|
|
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
|
-
#
|
|
1274
|
-
#
|
|
1275
|
-
#
|
|
1276
|
-
#
|
|
1277
|
-
#
|
|
1278
|
-
#
|
|
1279
|
-
#
|
|
1280
|
-
#
|
|
1281
|
-
#
|
|
1282
|
-
#
|
|
1283
|
-
#
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
1289
|
-
|
|
1290
|
-
if
|
|
1291
|
-
|
|
1408
|
+
while pos < length
|
|
1409
|
+
chr = source[pos]
|
|
1410
|
+
if chr == "\\"
|
|
1411
|
+
pos += 2
|
|
1292
1412
|
next
|
|
1293
1413
|
end
|
|
1294
|
-
if
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|