rigortype 0.1.6 → 0.1.8
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 +40 -29
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/check_rules.rb +60 -3
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- data/lib/rigor/builtins/static_return_refinements.rb +23 -1
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +78 -3
- data/lib/rigor/configuration.rb +21 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +22 -0
- data/lib/rigor/environment.rb +13 -0
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/expression_typer.rb +152 -14
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +57 -11
- data/lib/rigor/inference/method_dispatcher.rb +50 -5
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/scope_indexer.rb +209 -13
- data/lib/rigor/inference/statement_evaluator.rb +91 -10
- data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- data/sig/rigor.rbs +1 -0
- metadata +8 -1
|
@@ -950,7 +950,7 @@ module Rigor
|
|
|
950
950
|
end
|
|
951
951
|
|
|
952
952
|
def simple_dispatch_name?(name)
|
|
953
|
-
%i[nil? ! is_a? kind_of? instance_of? == != ===].include?(name)
|
|
953
|
+
%i[nil? ! is_a? kind_of? instance_of? == != === =~].include?(name)
|
|
954
954
|
end
|
|
955
955
|
|
|
956
956
|
def dispatch_call_simple(node, scope, name)
|
|
@@ -960,9 +960,111 @@ module Rigor
|
|
|
960
960
|
when :instance_of? then analyse_class_predicate(node, scope, exact: true)
|
|
961
961
|
when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
|
|
962
962
|
when :=== then analyse_case_equality_predicate(node, scope)
|
|
963
|
+
when :=~ then analyse_regex_match_predicate(node, scope)
|
|
963
964
|
end
|
|
964
965
|
end
|
|
965
966
|
|
|
967
|
+
# Survey item (b): `/regex/ =~ str` and `str =~ /regex/`
|
|
968
|
+
# bind the regex match-data globals on each edge.
|
|
969
|
+
#
|
|
970
|
+
# - Truthy edge (`=~` returned an Integer position — the
|
|
971
|
+
# match succeeded): `$~` to `Nominal[MatchData]`; `$&`
|
|
972
|
+
# and `$1..$N` (where N is the number of capture groups
|
|
973
|
+
# in the regex source) to `Nominal[String]`. This is the
|
|
974
|
+
# same optimistic-narrowing shape the existing
|
|
975
|
+
# `analyse_match_write` uses for named captures inside
|
|
976
|
+
# `if /(?<x>...)/ =~ str` — optional groups in the
|
|
977
|
+
# regex source (`(\d+)?`) would bind `$N` to `nil` at
|
|
978
|
+
# runtime, but the floor here matches the common idiom
|
|
979
|
+
# (required captures) and lets `unless /(\d+)/ =~ s;
|
|
980
|
+
# raise; end; $1.to_i` resolve cleanly.
|
|
981
|
+
# - Falsey edge (`=~` returned nil — no match): `$~` and
|
|
982
|
+
# every numbered / back-reference global bound to
|
|
983
|
+
# `Constant<nil>`.
|
|
984
|
+
#
|
|
985
|
+
# Returns nil (no narrowing) when the receiver / argument
|
|
986
|
+
# pair does not include a `RegularExpressionNode` literal
|
|
987
|
+
# we can count.
|
|
988
|
+
def analyse_regex_match_predicate(node, scope)
|
|
989
|
+
return nil if node.arguments.nil?
|
|
990
|
+
return nil unless node.arguments.arguments.size == 1
|
|
991
|
+
|
|
992
|
+
regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
|
|
993
|
+
return nil if regex_node.nil?
|
|
994
|
+
|
|
995
|
+
group_count = count_regex_capture_groups(regex_node.unescaped)
|
|
996
|
+
regex_match_predicate_scopes(scope, group_count)
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def regex_match_literal(left, right)
|
|
1000
|
+
return left if left.is_a?(Prism::RegularExpressionNode)
|
|
1001
|
+
return right if right.is_a?(Prism::RegularExpressionNode)
|
|
1002
|
+
|
|
1003
|
+
nil
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# Curated set of back-reference globals bound by every
|
|
1007
|
+
# `=~`. Numbered references (`$1..$N`) are handled
|
|
1008
|
+
# separately because N depends on the regex source.
|
|
1009
|
+
REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
|
|
1010
|
+
private_constant :REGEX_MATCH_GLOBALS
|
|
1011
|
+
|
|
1012
|
+
def regex_match_predicate_scopes(scope, group_count)
|
|
1013
|
+
string_t = Type::Combinator.nominal_of("String")
|
|
1014
|
+
match_data_t = Type::Combinator.nominal_of("MatchData")
|
|
1015
|
+
nil_t = Type::Combinator.constant_of(nil)
|
|
1016
|
+
|
|
1017
|
+
truthy = scope
|
|
1018
|
+
falsey = scope
|
|
1019
|
+
truthy = truthy.with_global(:$~, match_data_t)
|
|
1020
|
+
falsey = falsey.with_global(:$~, nil_t)
|
|
1021
|
+
REGEX_MATCH_GLOBALS.each do |name|
|
|
1022
|
+
next if name == :$~
|
|
1023
|
+
|
|
1024
|
+
truthy = truthy.with_global(name, string_t)
|
|
1025
|
+
falsey = falsey.with_global(name, nil_t)
|
|
1026
|
+
end
|
|
1027
|
+
group_count.times do |i|
|
|
1028
|
+
name = :"$#{i + 1}"
|
|
1029
|
+
truthy = truthy.with_global(name, string_t)
|
|
1030
|
+
falsey = falsey.with_global(name, nil_t)
|
|
1031
|
+
end
|
|
1032
|
+
[truthy, falsey]
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Counts capture groups (numbered + named — both
|
|
1036
|
+
# contribute to `$1..$N`) in a regex source. Backslash
|
|
1037
|
+
# escapes are skipped; non-capturing `(?:...)`, lookahead
|
|
1038
|
+
# `(?=...)` / `(?!...)`, and lookbehind `(?<=...)` /
|
|
1039
|
+
# `(?<!...)` do NOT count. Named groups `(?<name>...)`
|
|
1040
|
+
# DO count. The walker is intentionally light — it does
|
|
1041
|
+
# not parse the regex AST, just scans char-by-char — so
|
|
1042
|
+
# exotic constructs that overlap the lookaround syntax
|
|
1043
|
+
# may miscount; the unsoundness is bounded (over- or
|
|
1044
|
+
# under-binding a few `$N` globals) and we already accept
|
|
1045
|
+
# the same shape of unsoundness for `analyse_match_write`.
|
|
1046
|
+
def count_regex_capture_groups(source)
|
|
1047
|
+
i = 0
|
|
1048
|
+
total = 0
|
|
1049
|
+
length = source.length
|
|
1050
|
+
while i < length
|
|
1051
|
+
c = source[i]
|
|
1052
|
+
if c == "\\"
|
|
1053
|
+
i += 2
|
|
1054
|
+
next
|
|
1055
|
+
end
|
|
1056
|
+
if c == "("
|
|
1057
|
+
if source[i + 1] == "?"
|
|
1058
|
+
total += 1 if source[i + 2] == "<" && source[i + 3] != "=" && source[i + 3] != "!"
|
|
1059
|
+
else
|
|
1060
|
+
total += 1
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
i += 1
|
|
1064
|
+
end
|
|
1065
|
+
total
|
|
1066
|
+
end
|
|
1067
|
+
|
|
966
1068
|
def dispatch_call_numeric(node, scope, name)
|
|
967
1069
|
if COMPARISON_OPERATORS.include?(name)
|
|
968
1070
|
analyse_comparison_predicate(node, scope, comparator: name)
|
|
@@ -109,12 +109,11 @@ module Rigor
|
|
|
109
109
|
discovered_methods = build_discovered_methods(root)
|
|
110
110
|
seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
|
|
111
111
|
|
|
112
|
-
# v0.0.2 #5
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
|
|
112
|
+
# v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
|
|
113
|
+
# def nodes, the class -> superclass map, and the
|
|
114
|
+
# class/module -> included-modules map, each merged under
|
|
115
|
+
# the cross-file pre-pass seed (see below).
|
|
116
|
+
seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
|
|
118
117
|
|
|
119
118
|
# v0.1.2 — per-class table of method visibilities
|
|
120
119
|
# (`:public` / `:private` / `:protected`). The
|
|
@@ -134,6 +133,31 @@ module Rigor
|
|
|
134
133
|
table
|
|
135
134
|
end
|
|
136
135
|
|
|
136
|
+
# v0.0.2 #5 + ADR-24 slice 2 — seeds the three
|
|
137
|
+
# project-method indexes onto `seeded_scope`: the
|
|
138
|
+
# per-instance-method def-node table, the class ->
|
|
139
|
+
# superclass map, and the class/module -> included-modules
|
|
140
|
+
# map. Each per-file table is merged UNDER the cross-file
|
|
141
|
+
# `discovered_def_index_for_paths` seed carried on
|
|
142
|
+
# `default_scope` — same-file declarations win per entry,
|
|
143
|
+
# the cross-file seed supplies sibling-file ancestors.
|
|
144
|
+
def merge_project_method_indexes(seeded_scope, default_scope, root)
|
|
145
|
+
def_nodes = default_scope.discovered_def_nodes.merge(
|
|
146
|
+
build_discovered_def_nodes(root)
|
|
147
|
+
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
148
|
+
superclasses = default_scope.discovered_superclasses.merge(
|
|
149
|
+
build_discovered_superclasses(root)
|
|
150
|
+
)
|
|
151
|
+
includes = default_scope.discovered_includes.merge(
|
|
152
|
+
build_discovered_includes(root)
|
|
153
|
+
) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
|
|
154
|
+
|
|
155
|
+
seeded_scope
|
|
156
|
+
.with_discovered_def_nodes(def_nodes)
|
|
157
|
+
.with_discovered_superclasses(superclasses)
|
|
158
|
+
.with_discovered_includes(includes)
|
|
159
|
+
end
|
|
160
|
+
|
|
137
161
|
# Slice 7 phase 2. Builds the class-level ivar accumulator
|
|
138
162
|
# by walking every `Prism::ClassNode` / `Prism::ModuleNode`
|
|
139
163
|
# body, descending into each nested `Prism::DefNode`, and
|
|
@@ -343,11 +367,26 @@ module Rigor
|
|
|
343
367
|
unless qualified_prefix.empty?
|
|
344
368
|
body_scope = body_scope.with_self_type(Type::Combinator.singleton_of(qualified_prefix.join("::")))
|
|
345
369
|
end
|
|
346
|
-
rvalue_type = body_scope.type_of(node.value)
|
|
370
|
+
rvalue_type = meta_new_constant_type(node, full) || body_scope.type_of(node.value)
|
|
347
371
|
existing = accumulator[full]
|
|
348
372
|
accumulator[full] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
|
|
349
373
|
end
|
|
350
374
|
|
|
375
|
+
# Survey item (e): when the rvalue is a recognised
|
|
376
|
+
# `Module.new do ... end` / `Class.new do ... end` /
|
|
377
|
+
# `Struct.new(*sym) do ... end` / `Data.define(*sym) do
|
|
378
|
+
# ... end` form, type the named constant as
|
|
379
|
+
# `Singleton[<full>]` so the discovered-method table
|
|
380
|
+
# registered under `full` becomes reachable through
|
|
381
|
+
# singleton-side dispatch (`Const.[]=` etc.). Returns nil
|
|
382
|
+
# for non-meta-new rvalues so the caller falls back to the
|
|
383
|
+
# default `body_scope.type_of(node.value)` shape.
|
|
384
|
+
def meta_new_constant_type(node, full)
|
|
385
|
+
return nil unless meta_new_block_body(node)
|
|
386
|
+
|
|
387
|
+
Type::Combinator.singleton_of(full)
|
|
388
|
+
end
|
|
389
|
+
|
|
351
390
|
# Slice 7 phase 12 — in-source method discovery pre-pass.
|
|
352
391
|
# Walks every class/module body and records the methods
|
|
353
392
|
# introduced via `Prism::DefNode` (instance + singleton)
|
|
@@ -429,16 +468,22 @@ module Rigor
|
|
|
429
468
|
# v0.1.2 — when a `Const = Data.define(*sym) do ... end`
|
|
430
469
|
# / `Const = Struct.new(*sym) do ... end` constant write
|
|
431
470
|
# carries a block, the block body holds method overrides
|
|
432
|
-
# whose canonical class is `Const`.
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
471
|
+
# whose canonical class is `Const`. Survey item (e) extended
|
|
472
|
+
# the recognition to `Const = Module.new do ... end` and
|
|
473
|
+
# `Const = Class.new(?super) do ... end` — the
|
|
474
|
+
# ADR-16 Tier A "block-as-method" idiom at constant-write
|
|
475
|
+
# position. Returns the block body node (a
|
|
476
|
+
# `Prism::StatementsNode`) when the rvalue matches; nil
|
|
477
|
+
# otherwise. Used by `walk_methods` / `walk_def_nodes` to
|
|
478
|
+
# push `Const` onto the qualified prefix before recursing.
|
|
437
479
|
def meta_new_block_body(node)
|
|
438
480
|
return nil unless node.is_a?(Prism::ConstantWriteNode)
|
|
439
481
|
|
|
440
482
|
rvalue = node.value
|
|
441
|
-
return nil unless data_define_call?(rvalue) ||
|
|
483
|
+
return nil unless data_define_call?(rvalue) ||
|
|
484
|
+
struct_new_call?(rvalue) ||
|
|
485
|
+
module_new_call?(rvalue) ||
|
|
486
|
+
class_new_call?(rvalue)
|
|
442
487
|
|
|
443
488
|
rvalue.block&.body
|
|
444
489
|
end
|
|
@@ -559,6 +604,94 @@ module Rigor
|
|
|
559
604
|
accumulator[class_name][def_node.name] = def_node
|
|
560
605
|
end
|
|
561
606
|
|
|
607
|
+
# ADR-24 slice 2 — per-class table mapping a fully
|
|
608
|
+
# qualified user class to its superclass name AS WRITTEN
|
|
609
|
+
# at the `class Foo < Bar` declaration. Only constant
|
|
610
|
+
# superclasses are recorded (`class Foo < Struct.new(...)`
|
|
611
|
+
# and other non-constant superclasses produce no entry).
|
|
612
|
+
# The as-written name is resolved to a qualified class at
|
|
613
|
+
# the call site against the subclass's lexical nesting —
|
|
614
|
+
# see `ExpressionTyper#resolve_ancestor_class_name`.
|
|
615
|
+
def build_discovered_superclasses(root)
|
|
616
|
+
accumulator = {}
|
|
617
|
+
walk_class_superclasses(root, [], accumulator)
|
|
618
|
+
accumulator.freeze
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def walk_class_superclasses(node, qualified_prefix, accumulator)
|
|
622
|
+
return unless node.is_a?(Prism::Node)
|
|
623
|
+
|
|
624
|
+
case node
|
|
625
|
+
when Prism::ClassNode
|
|
626
|
+
name = qualified_name_for(node.constant_path)
|
|
627
|
+
if name
|
|
628
|
+
full = (qualified_prefix + [name]).join("::")
|
|
629
|
+
superclass = node.superclass && qualified_name_for(node.superclass)
|
|
630
|
+
accumulator[full] = superclass if superclass
|
|
631
|
+
walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
|
|
632
|
+
return
|
|
633
|
+
end
|
|
634
|
+
when Prism::ModuleNode
|
|
635
|
+
name = qualified_name_for(node.constant_path)
|
|
636
|
+
if name
|
|
637
|
+
walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
|
|
638
|
+
return
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
node.compact_child_nodes.each do |child|
|
|
643
|
+
walk_class_superclasses(child, qualified_prefix, accumulator)
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
MIXIN_CALL_NAMES = %i[include prepend].freeze
|
|
648
|
+
|
|
649
|
+
# ADR-24 slice 2 — per-class/module table mapping a fully
|
|
650
|
+
# qualified user class or module to the list of module
|
|
651
|
+
# names it `include`s / `prepend`s, AS WRITTEN at the
|
|
652
|
+
# mixin call (`include Foo` / `include Foo::Bar`). Only
|
|
653
|
+
# constant arguments are recorded; dynamic mixins
|
|
654
|
+
# (`include some_method`) produce no entry. `prepend` is
|
|
655
|
+
# bucketed with `include` — both contribute instance
|
|
656
|
+
# methods to the ancestor chain. `extend` is NOT tracked
|
|
657
|
+
# (it adds singleton methods; ADR-24 slice 2 resolves the
|
|
658
|
+
# instance-side chain).
|
|
659
|
+
def build_discovered_includes(root)
|
|
660
|
+
accumulator = {}
|
|
661
|
+
walk_class_includes(root, [], nil, accumulator)
|
|
662
|
+
accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def walk_class_includes(node, qualified_prefix, current_class, accumulator)
|
|
666
|
+
return unless node.is_a?(Prism::Node)
|
|
667
|
+
|
|
668
|
+
case node
|
|
669
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
670
|
+
name = qualified_name_for(node.constant_path)
|
|
671
|
+
if name
|
|
672
|
+
full = (qualified_prefix + [name]).join("::")
|
|
673
|
+
walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body
|
|
674
|
+
return
|
|
675
|
+
end
|
|
676
|
+
when Prism::CallNode
|
|
677
|
+
record_mixin_call(node, current_class, accumulator)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
node.compact_child_nodes.each do |child|
|
|
681
|
+
walk_class_includes(child, qualified_prefix, current_class, accumulator)
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def record_mixin_call(node, current_class, accumulator)
|
|
686
|
+
return unless current_class && node.receiver.nil?
|
|
687
|
+
return unless MIXIN_CALL_NAMES.include?(node.name)
|
|
688
|
+
|
|
689
|
+
node.arguments&.arguments&.each do |arg|
|
|
690
|
+
mod = qualified_name_for(arg)
|
|
691
|
+
(accumulator[current_class] ||= []) << mod if mod
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
562
695
|
VISIBILITY_MODIFIERS = %i[public private protected].freeze
|
|
563
696
|
|
|
564
697
|
# v0.1.2 — per-class method-visibility table for the
|
|
@@ -824,6 +957,44 @@ module Rigor
|
|
|
824
957
|
accumulator.freeze
|
|
825
958
|
end
|
|
826
959
|
|
|
960
|
+
# ADR-24 slice 2 — cross-file companion to
|
|
961
|
+
# `discovered_classes_for_paths`. Walks every project
|
|
962
|
+
# file once and returns both the merged
|
|
963
|
+
# `discovered_def_nodes` table (a class reopened across
|
|
964
|
+
# files has its method tables merged) and the merged
|
|
965
|
+
# class -> superclass-name map. The engine consults these
|
|
966
|
+
# so an implicit-self call inside a subclass resolves
|
|
967
|
+
# against a superclass `def` declared in a sibling file
|
|
968
|
+
# (`Mastodon::CLI::Accounts` calling a helper defined in
|
|
969
|
+
# `Mastodon::CLI::Base`).
|
|
970
|
+
#
|
|
971
|
+
# @param paths [Array<String>] project file paths.
|
|
972
|
+
# @param buffer [Rigor::Analysis::BufferBinding, nil]
|
|
973
|
+
# @return [Hash{Symbol => Hash}] `{ def_nodes:, superclasses: }`
|
|
974
|
+
def discovered_def_index_for_paths(paths, buffer: nil)
|
|
975
|
+
def_nodes = {}
|
|
976
|
+
superclasses = {}
|
|
977
|
+
includes = {}
|
|
978
|
+
paths.each do |path|
|
|
979
|
+
physical = buffer ? buffer.resolve(path) : path
|
|
980
|
+
root = Prism.parse(File.read(physical), filepath: path).value
|
|
981
|
+
build_discovered_def_nodes(root).each do |class_name, methods|
|
|
982
|
+
(def_nodes[class_name] ||= {}).merge!(methods)
|
|
983
|
+
end
|
|
984
|
+
superclasses.merge!(build_discovered_superclasses(root))
|
|
985
|
+
build_discovered_includes(root).each do |class_name, mods|
|
|
986
|
+
includes[class_name] = ((includes[class_name] || []) + mods).uniq
|
|
987
|
+
end
|
|
988
|
+
rescue StandardError
|
|
989
|
+
# Skip files that fail to parse or read; the per-file
|
|
990
|
+
# analyzer surfaces the parse error separately.
|
|
991
|
+
next
|
|
992
|
+
end
|
|
993
|
+
def_nodes.each_value(&:freeze)
|
|
994
|
+
includes.each_value(&:freeze)
|
|
995
|
+
{ def_nodes: def_nodes.freeze, superclasses: superclasses.freeze, includes: includes.freeze }
|
|
996
|
+
end
|
|
997
|
+
|
|
827
998
|
# Class-only variant of `record_declarations` — descends
|
|
828
999
|
# into nested module bodies (so `module Foo; class Bar`
|
|
829
1000
|
# registers `Foo::Bar`) but never registers the module
|
|
@@ -946,6 +1117,31 @@ module Rigor
|
|
|
946
1117
|
positional.all?(Prism::SymbolNode)
|
|
947
1118
|
end
|
|
948
1119
|
|
|
1120
|
+
# Recognises `Module.new` and `Module.new(&block)` /
|
|
1121
|
+
# `Module.new do ... end` at constant-write rvalue
|
|
1122
|
+
# position. The block body is the anonymous module's
|
|
1123
|
+
# `module_eval` body; defs inside it bind methods on the
|
|
1124
|
+
# named constant (`Const = Module.new do ...; def foo; ...; end; end`).
|
|
1125
|
+
# Arguments are NOT inspected because `Module.new` accepts
|
|
1126
|
+
# no positionals — Ruby raises ArgumentError if any are
|
|
1127
|
+
# passed — so a malformed call falls through the walker
|
|
1128
|
+
# without affecting analysis.
|
|
1129
|
+
def module_new_call?(node)
|
|
1130
|
+
meta_call_with_name?(node, :Module, :new)
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
# Recognises `Class.new`, `Class.new(super_class)`, and the
|
|
1134
|
+
# block form `Class.new { ... }`. Like `module_new_call?`,
|
|
1135
|
+
# the block body is walked as the anonymous class's body.
|
|
1136
|
+
# The optional `super_class` positional is accepted but does
|
|
1137
|
+
# NOT route through `ancestor` discovery in this slice — the
|
|
1138
|
+
# synthesised class still answers method lookups via its
|
|
1139
|
+
# own body's defs, mirroring how `Struct.new` / `Data.define`
|
|
1140
|
+
# are handled.
|
|
1141
|
+
def class_new_call?(node)
|
|
1142
|
+
meta_call_with_name?(node, :Class, :new)
|
|
1143
|
+
end
|
|
1144
|
+
|
|
949
1145
|
def meta_call_with_name?(node, receiver_name, method_name)
|
|
950
1146
|
return false unless node.is_a?(Prism::CallNode)
|
|
951
1147
|
return false unless node.name == method_name
|
|
@@ -358,9 +358,9 @@ module Rigor
|
|
|
358
358
|
# is the falsey edge of the predicate (subsequent
|
|
359
359
|
# statements observe the predicate-was-false world).
|
|
360
360
|
return [Type::Combinator.union(then_type, else_type), falsey_scope] \
|
|
361
|
-
if
|
|
361
|
+
if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
|
|
362
362
|
return [Type::Combinator.union(then_type, else_type), truthy_scope] \
|
|
363
|
-
if
|
|
363
|
+
if branch_terminates?(node.subsequent, else_type) && node.statements
|
|
364
364
|
|
|
365
365
|
[
|
|
366
366
|
Type::Combinator.union(then_type, else_type),
|
|
@@ -385,9 +385,9 @@ module Rigor
|
|
|
385
385
|
# `if`: when the body unconditionally exits and there
|
|
386
386
|
# is no else, the post-scope is the truthy edge.
|
|
387
387
|
return [Type::Combinator.union(then_type, else_type), truthy_scope] \
|
|
388
|
-
if
|
|
388
|
+
if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
|
|
389
389
|
return [Type::Combinator.union(then_type, else_type), falsey_scope] \
|
|
390
|
-
if
|
|
390
|
+
if branch_terminates?(node.else_clause, else_type) && node.statements
|
|
391
391
|
|
|
392
392
|
[
|
|
393
393
|
Type::Combinator.union(then_type, else_type),
|
|
@@ -497,13 +497,25 @@ module Rigor
|
|
|
497
497
|
def eval_begin(node)
|
|
498
498
|
primary_type, primary_scope = eval_begin_primary(node)
|
|
499
499
|
rescue_chain = collect_rescue_chain_results(node.rescue_clause, scope)
|
|
500
|
-
|
|
501
|
-
|
|
500
|
+
# Rescue arms whose body unconditionally exits (`return`,
|
|
501
|
+
# `next`, `break`, `raise`, `throw`, `exit`, `abort`,
|
|
502
|
+
# `fail`) contribute neither a type fragment NOR a scope
|
|
503
|
+
# to the post-begin flow — control left the `begin` via
|
|
504
|
+
# that arm. Mirrors the `eval_if` / `eval_unless` /
|
|
505
|
+
# `eval_and_or` early-return narrowing. Without this
|
|
506
|
+
# filter, a `rescue ... return` on a local bound only in
|
|
507
|
+
# the primary body nil-injects that local across the
|
|
508
|
+
# join, defeating the rescue arm's whole point of guaranteeing
|
|
509
|
+
# the primary local is in scope for downstream statements.
|
|
510
|
+
live_rescues = rescue_chain.reject { |_pair, arm_node| branch_unconditionally_exits?(arm_node.statements) }
|
|
511
|
+
.map(&:first)
|
|
512
|
+
|
|
513
|
+
if live_rescues.empty?
|
|
502
514
|
exit_type = primary_type
|
|
503
515
|
exit_scope = primary_scope
|
|
504
516
|
else
|
|
505
|
-
exit_type = Type::Combinator.union(primary_type, *
|
|
506
|
-
exit_scope = reduce_scopes_with_nil_injection([primary_scope, *
|
|
517
|
+
exit_type = Type::Combinator.union(primary_type, *live_rescues.map(&:first))
|
|
518
|
+
exit_scope = reduce_scopes_with_nil_injection([primary_scope, *live_rescues.map(&:last)])
|
|
507
519
|
end
|
|
508
520
|
|
|
509
521
|
if node.ensure_clause
|
|
@@ -540,7 +552,7 @@ module Rigor
|
|
|
540
552
|
current = rescue_node
|
|
541
553
|
while current
|
|
542
554
|
rescue_scope = bind_rescue_reference(current, entry_scope)
|
|
543
|
-
results << eval_branch_or_nil(current.statements, rescue_scope)
|
|
555
|
+
results << [eval_branch_or_nil(current.statements, rescue_scope), current]
|
|
544
556
|
current = current.subsequent
|
|
545
557
|
end
|
|
546
558
|
results
|
|
@@ -696,11 +708,36 @@ module Rigor
|
|
|
696
708
|
# edge-aware: `a && b` can only produce the falsey fragment of
|
|
697
709
|
# `a` when the RHS is skipped, while `a || b` can only produce
|
|
698
710
|
# the truthy fragment of `a` when the RHS is skipped.
|
|
711
|
+
#
|
|
712
|
+
# When the RHS is a terminating branch — it `raise`s /
|
|
713
|
+
# `return`s / `throw`s / `exit`s / `break`s / `next`s, OR its
|
|
714
|
+
# inferred type is `Bot` (ADR-24 WD6: a divergent helper such
|
|
715
|
+
# as `a or fail_with_message(...)`, recognised via
|
|
716
|
+
# `branch_terminates?`) — the post-OR / post-AND scope is the
|
|
717
|
+
# LHS-skipped edge alone: `a or raise` only survives when `a`
|
|
718
|
+
# was truthy, so subsequent statements observe `a` narrowed to
|
|
719
|
+
# its truthy fragment; the symmetric `a and raise` survives
|
|
720
|
+
# only when `a` was falsey. Same shape as the `eval_if` /
|
|
721
|
+
# `eval_unless` early-return narrowing.
|
|
699
722
|
def eval_and_or(node)
|
|
700
723
|
left_type, left_scope = sub_eval(node.left, scope)
|
|
701
724
|
truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
|
|
702
725
|
rhs_entry = node.is_a?(Prism::AndNode) ? truthy_left : falsey_left
|
|
703
726
|
right_type, right_scope = sub_eval(node.right, rhs_entry)
|
|
727
|
+
|
|
728
|
+
if branch_terminates?(node.right, right_type)
|
|
729
|
+
# Control never reaches any statement after `a or raise`
|
|
730
|
+
# via the RHS edge — the RHS scope is discarded.
|
|
731
|
+
surviving_type =
|
|
732
|
+
if node.is_a?(Prism::AndNode)
|
|
733
|
+
Narrowing.narrow_falsey(left_type)
|
|
734
|
+
else
|
|
735
|
+
Narrowing.narrow_truthy(left_type)
|
|
736
|
+
end
|
|
737
|
+
surviving_scope = node.is_a?(Prism::AndNode) ? falsey_left : truthy_left
|
|
738
|
+
return [surviving_type, surviving_scope]
|
|
739
|
+
end
|
|
740
|
+
|
|
704
741
|
skipped_type =
|
|
705
742
|
if node.is_a?(Prism::AndNode)
|
|
706
743
|
Narrowing.narrow_falsey(left_type)
|
|
@@ -1106,7 +1143,16 @@ module Rigor
|
|
|
1106
1143
|
# narrowing logic via `Narrowing.narrow_for_fact` so the
|
|
1107
1144
|
# predicate / assert / plugin paths all converge on the
|
|
1108
1145
|
# same hierarchy-aware narrowing rules.
|
|
1146
|
+
#
|
|
1147
|
+
# v0.1.8 Pillar 2 Slice 1 added the `:local` target_kind
|
|
1148
|
+
# branch so plugins recognising bespoke call shapes
|
|
1149
|
+
# (`expect(x).to be_a(T)`) can directly narrow a named
|
|
1150
|
+
# local in the surrounding scope, bypassing the
|
|
1151
|
+
# parameter-name lookup that requires an authoritative RBS
|
|
1152
|
+
# sig on the called method (which RSpec matchers lack).
|
|
1109
1153
|
def apply_post_return_fact(fact, call_node, current_scope, method_def)
|
|
1154
|
+
return apply_local_post_return_fact(fact, current_scope) if fact.target_kind == :local
|
|
1155
|
+
|
|
1110
1156
|
target_node = fact_target_node(fact, call_node, method_def)
|
|
1111
1157
|
return apply_self_post_return_fact(fact, target_node, current_scope) if fact.target_kind == :self
|
|
1112
1158
|
return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
|
|
@@ -1119,6 +1165,21 @@ module Rigor
|
|
|
1119
1165
|
current_scope.with_local(local_name, narrowed)
|
|
1120
1166
|
end
|
|
1121
1167
|
|
|
1168
|
+
# v0.1.8 Pillar 2 Slice 1 — narrows the named local directly
|
|
1169
|
+
# without consulting the call node's argument list. The fact's
|
|
1170
|
+
# `target_name` is the local-variable name as written in
|
|
1171
|
+
# source. Silently no-ops when the local is unbound in the
|
|
1172
|
+
# current scope (the plugin's named local may have already
|
|
1173
|
+
# gone out of scope when the contribution fires).
|
|
1174
|
+
def apply_local_post_return_fact(fact, current_scope)
|
|
1175
|
+
local_name = fact.target_name
|
|
1176
|
+
current_type = current_scope.local(local_name)
|
|
1177
|
+
return current_scope if current_type.nil?
|
|
1178
|
+
|
|
1179
|
+
narrowed = Narrowing.narrow_for_fact(current_type, fact, current_scope.environment)
|
|
1180
|
+
current_scope.with_local(local_name, narrowed)
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1122
1183
|
# v0.1.1 Track 1 slice 3 — `assert self is T` post-return
|
|
1123
1184
|
# narrowing for the four supported receiver shapes (mirrors
|
|
1124
1185
|
# `Narrowing#apply_self_fact`).
|
|
@@ -1423,7 +1484,7 @@ module Rigor
|
|
|
1423
1484
|
# ScopeIndexer-populated declaration overrides
|
|
1424
1485
|
# (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
|
|
1425
1486
|
# remain reachable from inside nested bodies.
|
|
1426
|
-
def build_fresh_body_scope
|
|
1487
|
+
def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
|
|
1427
1488
|
Scope.empty(environment: scope.environment)
|
|
1428
1489
|
.with_declared_types(scope.declared_types)
|
|
1429
1490
|
.with_discovered_classes(scope.discovered_classes)
|
|
@@ -1432,6 +1493,9 @@ module Rigor
|
|
|
1432
1493
|
.with_class_cvars(scope.class_cvars)
|
|
1433
1494
|
.with_program_globals(scope.program_globals)
|
|
1434
1495
|
.with_discovered_methods(scope.discovered_methods)
|
|
1496
|
+
.with_discovered_def_nodes(scope.discovered_def_nodes)
|
|
1497
|
+
.with_discovered_superclasses(scope.discovered_superclasses)
|
|
1498
|
+
.with_discovered_includes(scope.discovered_includes)
|
|
1435
1499
|
.with_discovered_method_visibilities(scope.discovered_method_visibilities)
|
|
1436
1500
|
end
|
|
1437
1501
|
|
|
@@ -1631,6 +1695,23 @@ module Rigor
|
|
|
1631
1695
|
end
|
|
1632
1696
|
end
|
|
1633
1697
|
|
|
1698
|
+
# ADR-24 WD6 / slice 3 — generalised terminating-branch
|
|
1699
|
+
# detection. `branch_unconditionally_exits?` recognises a
|
|
1700
|
+
# branch SYNTACTICALLY (return / next / break / a call
|
|
1701
|
+
# named raise / throw / exit / abort / fail). A branch
|
|
1702
|
+
# whose *inferred type is `Bot`* also terminates — it
|
|
1703
|
+
# cannot produce a value, so control never falls through
|
|
1704
|
+
# it — regardless of how it is spelled. The canonical
|
|
1705
|
+
# case is a resolved guard helper (`fail_with_message(...)`)
|
|
1706
|
+
# whose body always raises: ADR-24 slice 1 types the call
|
|
1707
|
+
# `bot`, and this OR-test makes `helper(...) if x.nil?`
|
|
1708
|
+
# narrow exactly like `raise ... if x.nil?`. The branch
|
|
1709
|
+
# type is already computed by `eval_if` / `eval_unless`.
|
|
1710
|
+
def branch_terminates?(branch_node, branch_type)
|
|
1711
|
+
branch_unconditionally_exits?(branch_node) ||
|
|
1712
|
+
branch_type.is_a?(Type::Bot)
|
|
1713
|
+
end
|
|
1714
|
+
|
|
1634
1715
|
def eval_branch_or_nil(branch_node, branch_scope)
|
|
1635
1716
|
return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
|
|
1636
1717
|
|
|
@@ -73,8 +73,8 @@ module Rigor
|
|
|
73
73
|
# This file ships the value class only. Slice 2b wires the
|
|
74
74
|
# pre-pass that scans Tier C call sites + the
|
|
75
75
|
# `SyntheticMethodIndex` the dispatcher consults; slice 2c
|
|
76
|
-
# authors `
|
|
77
|
-
# `
|
|
76
|
+
# authors `plugins/rigor-dry-struct/` and
|
|
77
|
+
# `plugins/rigor-dry-types/` as the worked consumers.
|
|
78
78
|
class HeredocTemplate
|
|
79
79
|
NAME_PLACEHOLDER = "\#{name}"
|
|
80
80
|
|
|
@@ -83,7 +83,7 @@ module Rigor
|
|
|
83
83
|
# This file ships the value class only. Slice 3b wires the
|
|
84
84
|
# scanner that walks Tier B call sites + the per-method
|
|
85
85
|
# explosion via `SyntheticMethodIndex`; slice 3c authors
|
|
86
|
-
# `
|
|
86
|
+
# `plugins/rigor-devise/` model side as the worked consumer.
|
|
87
87
|
class TraitRegistry
|
|
88
88
|
REST_POSITION = :rest
|
|
89
89
|
|