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.
- checksums.yaml +4 -4
- data/README.md +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- 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 +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -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 +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- 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/overload_selector.rb +33 -1
- 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 +187 -55
- 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 +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- 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 +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -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/analyzer.rb +1 -2
- 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/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- 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/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- 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/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- 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-ci-setup/SKILL.md +319 -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 +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- 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
|
-
|
|
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 [
|
|
438
|
+
return [body_scope, scope] if current.nil?
|
|
371
439
|
|
|
372
|
-
accumulate_case_when_scopes(
|
|
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?
|
|
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
|
-
|
|
1174
|
-
regex_match_predicate_scopes(scope,
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
name = :"$#{
|
|
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
|
-
#
|
|
1214
|
-
#
|
|
1215
|
-
#
|
|
1216
|
-
#
|
|
1217
|
-
#
|
|
1218
|
-
#
|
|
1219
|
-
#
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1222
|
-
#
|
|
1223
|
-
#
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
1229
|
-
|
|
1230
|
-
if
|
|
1231
|
-
|
|
1408
|
+
while pos < length
|
|
1409
|
+
chr = source[pos]
|
|
1410
|
+
if chr == "\\"
|
|
1411
|
+
pos += 2
|
|
1232
1412
|
next
|
|
1233
1413
|
end
|
|
1234
|
-
if
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|