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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -29
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/check_rules.rb +60 -3
  5. data/lib/rigor/analysis/diagnostic.rb +17 -3
  6. data/lib/rigor/analysis/runner.rb +178 -3
  7. data/lib/rigor/analysis/worker_session.rb +14 -3
  8. data/lib/rigor/builtins/static_return_refinements.rb +23 -1
  9. data/lib/rigor/cli/baseline_command.rb +377 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +78 -3
  13. data/lib/rigor/configuration.rb +21 -1
  14. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  15. data/lib/rigor/environment/rbs_loader.rb +22 -0
  16. data/lib/rigor/environment.rb +13 -0
  17. data/lib/rigor/flow_contribution/fact.rb +20 -10
  18. data/lib/rigor/inference/expression_typer.rb +152 -14
  19. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +57 -11
  20. data/lib/rigor/inference/method_dispatcher.rb +50 -5
  21. data/lib/rigor/inference/narrowing.rb +103 -1
  22. data/lib/rigor/inference/scope_indexer.rb +209 -13
  23. data/lib/rigor/inference/statement_evaluator.rb +91 -10
  24. data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
  25. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  26. data/lib/rigor/scope.rb +46 -0
  27. data/lib/rigor/triage/catalogue.rb +296 -0
  28. data/lib/rigor/triage/hint.rb +27 -0
  29. data/lib/rigor/triage.rb +89 -0
  30. data/lib/rigor/version.rb +1 -1
  31. data/sig/rigor/environment.rbs +2 -0
  32. data/sig/rigor/inference.rbs +1 -0
  33. data/sig/rigor/scope.rbs +6 -0
  34. data/sig/rigor.rbs +1 -0
  35. 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 also record the def node itself for
113
- # instance methods so the engine can re-type the body
114
- # when a call site dispatches against a user-defined
115
- # method without an RBS sig.
116
- discovered_def_nodes = build_discovered_def_nodes(root)
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`. Returns the block body
433
- # node (a `Prism::StatementsNode`) when the rvalue
434
- # matches; nil otherwise. Used by `walk_methods` /
435
- # `walk_def_nodes` to push `Const` onto the qualified
436
- # prefix before recursing.
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) || struct_new_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 branch_unconditionally_exits?(node.statements) && node.subsequent.nil?
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 branch_unconditionally_exits?(node.subsequent) && node.statements
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 branch_unconditionally_exits?(node.statements) && node.else_clause.nil?
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 branch_unconditionally_exits?(node.else_clause) && node.statements
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
- if rescue_chain.empty?
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, *rescue_chain.map(&:first))
506
- exit_scope = reduce_scopes_with_nil_injection([primary_scope, *rescue_chain.map(&:last)])
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 `examples/rigor-dry-struct/` and
77
- # `examples/rigor-dry-types/` as the worked consumers.
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
- # `examples/rigor-devise/` model side as the worked consumer.
86
+ # `plugins/rigor-devise/` model side as the worked consumer.
87
87
  class TraitRegistry
88
88
  REST_POSITION = :rest
89
89