rigortype 0.0.9 → 0.1.0

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/lib/rigor/analysis/check_rules.rb +228 -40
  4. data/lib/rigor/analysis/diagnostic.rb +15 -1
  5. data/lib/rigor/analysis/runner.rb +183 -4
  6. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  7. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  8. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  9. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  10. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  11. data/lib/rigor/cache/store.rb +2 -0
  12. data/lib/rigor/cli.rb +9 -3
  13. data/lib/rigor/configuration/severity_profile.rb +109 -0
  14. data/lib/rigor/configuration.rb +110 -6
  15. data/lib/rigor/environment/rbs_loader.rb +89 -13
  16. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  17. data/lib/rigor/flow_contribution/element.rb +53 -0
  18. data/lib/rigor/flow_contribution/fact.rb +88 -0
  19. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  20. data/lib/rigor/flow_contribution/merger.rb +275 -0
  21. data/lib/rigor/flow_contribution.rb +51 -0
  22. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  23. data/lib/rigor/inference/expression_typer.rb +84 -5
  24. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  26. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  27. data/lib/rigor/inference/narrowing.rb +105 -130
  28. data/lib/rigor/inference/scope_indexer.rb +75 -1
  29. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  30. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  31. data/lib/rigor/plugin/base.rb +241 -0
  32. data/lib/rigor/plugin/io_boundary.rb +102 -0
  33. data/lib/rigor/plugin/load_error.rb +23 -0
  34. data/lib/rigor/plugin/loader.rb +191 -0
  35. data/lib/rigor/plugin/manifest.rb +134 -0
  36. data/lib/rigor/plugin/registry.rb +50 -0
  37. data/lib/rigor/plugin/services.rb +65 -0
  38. data/lib/rigor/plugin/trust_policy.rb +99 -0
  39. data/lib/rigor/plugin.rb +61 -0
  40. data/lib/rigor/rbs_extended.rb +57 -9
  41. data/lib/rigor/reflection.rb +2 -2
  42. data/lib/rigor/version.rb +1 -1
  43. data/lib/rigor.rb +7 -0
  44. data/sig/rigor/environment.rbs +7 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/rbs_extended.rbs +2 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +7 -0
  49. metadata +18 -1
@@ -340,7 +340,7 @@ module Rigor
340
340
  accumulator.transform_values(&:freeze).freeze
341
341
  end
342
342
 
343
- def walk_methods(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
343
+ def walk_methods(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
344
344
  return unless node.is_a?(Prism::Node)
345
345
 
346
346
  case node
@@ -359,6 +359,9 @@ module Rigor
359
359
  when Prism::DefNode
360
360
  record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
361
361
  return
362
+ when Prism::AliasMethodNode
363
+ record_alias_method(node, qualified_prefix, in_singleton_class, accumulator)
364
+ return
362
365
  when Prism::CallNode
363
366
  record_define_method(node, qualified_prefix, in_singleton_class, accumulator) if node.name == :define_method
364
367
  end
@@ -390,6 +393,7 @@ module Rigor
390
393
  def build_discovered_def_nodes(root)
391
394
  accumulator = {}
392
395
  walk_def_nodes(root, [], false, accumulator)
396
+ apply_alias_def_nodes(root, accumulator)
393
397
  accumulator.transform_values(&:freeze).freeze
394
398
  end
395
399
 
@@ -436,6 +440,76 @@ module Rigor
436
440
  accumulator[class_name][def_node.name] = def_node
437
441
  end
438
442
 
443
+ # Registers the alias name in the `discovered_methods` table so
444
+ # `undefined-method` diagnostics are not emitted for calls to the
445
+ # aliased name. The kind mirrors the surrounding class context
446
+ # (instance inside a regular class body, singleton inside
447
+ # `class << self`).
448
+ def record_alias_method(alias_node, qualified_prefix, in_singleton_class, accumulator)
449
+ return if qualified_prefix.empty?
450
+ return unless alias_node.new_name.is_a?(Prism::SymbolNode)
451
+
452
+ class_name = qualified_prefix.join("::")
453
+ new_name = alias_node.new_name.unescaped.to_sym
454
+ kind = in_singleton_class ? :singleton : :instance
455
+ (accumulator[class_name] ||= {})[new_name] = kind
456
+ end
457
+
458
+ # Post-pass over the `def_nodes` accumulator: for every `alias`
459
+ # declaration inside a class body, if the original method name
460
+ # maps to a `Prism::DefNode`, register the new name pointing to
461
+ # the same node so inter-procedural return-type inference works
462
+ # for the aliased name.
463
+ def apply_alias_def_nodes(root, accumulator)
464
+ alias_map = collect_class_alias_map(root, [], {})
465
+ alias_map.each do |class_name, aliases|
466
+ class_defs = accumulator[class_name]
467
+ next unless class_defs
468
+
469
+ aliases.each do |new_name, old_name|
470
+ def_node = class_defs[old_name]
471
+ next unless def_node.is_a?(Prism::DefNode)
472
+
473
+ (accumulator[class_name] ||= {})[new_name] = def_node
474
+ end
475
+ end
476
+ end
477
+
478
+ # Builds a map `{class_name => {new_name_sym => old_name_sym}}` by
479
+ # walking the tree for `AliasMethodNode` nodes inside class bodies.
480
+ # rubocop:disable Metrics/CyclomaticComplexity
481
+ def collect_class_alias_map(node, qualified_prefix, accumulator)
482
+ return accumulator unless node.is_a?(Prism::Node)
483
+
484
+ case node
485
+ when Prism::ClassNode, Prism::ModuleNode
486
+ name = qualified_name_for(node.constant_path)
487
+ if name
488
+ collect_class_alias_map(node.body, qualified_prefix + [name], accumulator) if node.body
489
+ return accumulator
490
+ end
491
+ when Prism::SingletonClassNode
492
+ return accumulator
493
+ when Prism::AliasMethodNode
494
+ record_alias_map_entry(node, qualified_prefix, accumulator)
495
+ return accumulator
496
+ end
497
+
498
+ node.compact_child_nodes.each { |child| collect_class_alias_map(child, qualified_prefix, accumulator) }
499
+ accumulator
500
+ end
501
+ # rubocop:enable Metrics/CyclomaticComplexity
502
+
503
+ def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
504
+ return if qualified_prefix.empty?
505
+ return unless alias_node.new_name.is_a?(Prism::SymbolNode) && alias_node.old_name.is_a?(Prism::SymbolNode)
506
+
507
+ class_name = qualified_prefix.join("::")
508
+ new_name = alias_node.new_name.unescaped.to_sym
509
+ old_name = alias_node.old_name.unescaped.to_sym
510
+ (accumulator[class_name] ||= {})[new_name] = old_name
511
+ end
512
+
439
513
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
440
514
  return if qualified_prefix.empty?
441
515
  return if call_node.arguments.nil? || call_node.arguments.arguments.empty?
@@ -85,6 +85,7 @@ module Rigor
85
85
  Prism::EnsureNode => :eval_ensure,
86
86
  Prism::WhileNode => :eval_loop,
87
87
  Prism::UntilNode => :eval_loop,
88
+ Prism::ForNode => :eval_for,
88
89
  Prism::AndNode => :eval_and_or,
89
90
  Prism::OrNode => :eval_and_or,
90
91
  Prism::ParenthesesNode => :eval_parentheses,
@@ -93,7 +94,8 @@ module Rigor
93
94
  Prism::ModuleNode => :eval_class_or_module,
94
95
  Prism::SingletonClassNode => :eval_singleton_class,
95
96
  Prism::CallNode => :eval_call,
96
- Prism::BlockNode => :eval_block
97
+ Prism::BlockNode => :eval_block,
98
+ Prism::MatchWriteNode => :eval_match_write
97
99
  }.freeze
98
100
  private_constant :HANDLERS
99
101
 
@@ -452,13 +454,26 @@ module Rigor
452
454
  results = []
453
455
  falsey_scope = entry_scope
454
456
  conditions.each do |branch|
455
- when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
456
- body_scope, falsey_scope = Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
457
+ body_scope, falsey_scope = branch_body_and_falsey_scopes(subject, branch, falsey_scope)
457
458
  results << sub_eval(branch, body_scope)
458
459
  end
459
460
  [results, falsey_scope]
460
461
  end
461
462
 
463
+ # Returns `[body_scope, updated_falsey_scope]` for a single branch.
464
+ # `InNode` branches apply pattern bindings; `WhenNode` branches
465
+ # narrow through `Narrowing.case_when_scopes`. The falsey scope is
466
+ # unchanged for `in` branches (conservative: no exhaustiveness
467
+ # tracking yet).
468
+ def branch_body_and_falsey_scopes(subject, branch, falsey_scope)
469
+ if branch.is_a?(Prism::InNode)
470
+ [apply_in_pattern_bindings(subject, branch.pattern, falsey_scope), falsey_scope]
471
+ else
472
+ when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
473
+ Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
474
+ end
475
+ end
476
+
462
477
  def eval_case_else(else_clause, falsey_scope)
463
478
  return sub_eval(else_clause, falsey_scope) if else_clause
464
479
 
@@ -524,7 +539,8 @@ module Rigor
524
539
  results = []
525
540
  current = rescue_node
526
541
  while current
527
- results << eval_branch_or_nil(current.statements, entry_scope)
542
+ rescue_scope = bind_rescue_reference(current, entry_scope)
543
+ results << eval_branch_or_nil(current.statements, rescue_scope)
528
544
  current = current.subsequent
529
545
  end
530
546
  results
@@ -554,6 +570,122 @@ module Rigor
554
570
  ]
555
571
  end
556
572
 
573
+ # `for index in collection; body; end`. Unlike `each {}` blocks,
574
+ # `for` does NOT create a new variable scope: the index variable
575
+ # AND every local written in the body leak to the surrounding
576
+ # scope. The collection is evaluated once; the body runs zero or
577
+ # more times, so the post-loop scope is the join of the
578
+ # no-iteration scope (just `post_collection`) and the body scope,
579
+ # with half-bound names degraded to `T | nil` via nil-injection.
580
+ # The loop expression itself types as `Constant[nil]` (the common
581
+ # case where no `break VALUE` is observed), matching the policy
582
+ # `eval_loop` uses for `while` / `until`.
583
+ def eval_for(node)
584
+ coll_type, post_coll = sub_eval(node.collection, scope)
585
+ element_type = for_iteration_element_type(coll_type)
586
+ body_entry = bind_for_index(node.index, element_type, post_coll)
587
+
588
+ body_scope = node.statements ? sub_eval(node.statements, body_entry).last : body_entry
589
+ [
590
+ Type::Combinator.constant_of(nil),
591
+ join_with_nil_injection(post_coll, body_scope)
592
+ ]
593
+ end
594
+
595
+ # `for x in coll` is semantically `coll.each { |x| ... }`. We
596
+ # ask the method dispatcher for `coll.each`'s expected block
597
+ # parameter types — that path consults RBS and the iterator
598
+ # dispatch table, which is more precise than the structural
599
+ # `collection_element_type` fallback (it knows, e.g., that
600
+ # `Hash[K, V]#each` yields `[K, V]` even when the receiver is
601
+ # not a literal Hash carrier in our local lattice). When the
602
+ # dispatcher returns nothing (no signature, unknown receiver)
603
+ # we fall back to the structural extractor.
604
+ def for_iteration_element_type(coll_type)
605
+ structural = collection_element_type(coll_type)
606
+ return structural unless structural.equal?(Type::Combinator.untyped)
607
+
608
+ block_params = MethodDispatcher.expected_block_param_types(
609
+ receiver_type: coll_type,
610
+ method_name: :each,
611
+ arg_types: [],
612
+ environment: scope.environment
613
+ )
614
+ return structural if block_params.nil? || block_params.empty?
615
+
616
+ block_params.size == 1 ? block_params.first : Type::Combinator.tuple_of(*block_params)
617
+ rescue StandardError
618
+ Type::Combinator.untyped
619
+ end
620
+
621
+ # Binds the `for` index variable(s) into `scope`. A single
622
+ # `LocalVariableTargetNode` is bound to `element_type` (the
623
+ # per-iteration value the collection yields). A `MultiTargetNode`
624
+ # (`for a, b in pairs`) delegates to {MultiTargetBinder}, which
625
+ # decomposes a tuple-shaped element into the inner slots.
626
+ def bind_for_index(index_node, element_type, scope)
627
+ case index_node
628
+ when Prism::LocalVariableTargetNode
629
+ scope.with_local(index_node.name, element_type)
630
+ when Prism::MultiTargetNode
631
+ MultiTargetBinder.bind(index_node, element_type)
632
+ .reduce(scope) { |s, (name, type)| s.with_local(name, type) }
633
+ else
634
+ scope
635
+ end
636
+ end
637
+
638
+ # Extracts the per-iteration element type from a collection
639
+ # carrier. `Tuple[T1..Tn]` yields the union of its elements;
640
+ # `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`;
641
+ # `Nominal[Hash, [K, V]]` yields `Tuple[K, V]` (Hash#each yields
642
+ # `[key, value]` pairs); `IntegerRange` yields `Integer`;
643
+ # `Constant<Range>` reads the literal range's element class.
644
+ # Anything else falls back to `untyped`.
645
+ def collection_element_type(type)
646
+ case type
647
+ when Type::Tuple
648
+ type.elements.empty? ? Type::Combinator.untyped : Type::Combinator.union(*type.elements)
649
+ when Type::Nominal
650
+ nominal_element_type(type)
651
+ when Type::IntegerRange
652
+ Type::Combinator.nominal_of("Integer")
653
+ when Type::Constant
654
+ constant_element_type(type)
655
+ else
656
+ Type::Combinator.untyped
657
+ end
658
+ end
659
+
660
+ def constant_element_type(constant)
661
+ value = constant.value
662
+ case value
663
+ when Range
664
+ first = value.first
665
+ first.nil? ? Type::Combinator.untyped : Type::Combinator.nominal_of(first.class.name)
666
+ when Array
667
+ return Type::Combinator.untyped if value.empty?
668
+
669
+ Type::Combinator.union(*value.map { |v| Type::Combinator.constant_of(v) })
670
+ else
671
+ Type::Combinator.untyped
672
+ end
673
+ rescue StandardError
674
+ Type::Combinator.untyped
675
+ end
676
+
677
+ def nominal_element_type(nominal)
678
+ args = nominal.type_args
679
+ case nominal.class_name
680
+ when "Array", "Range", "Set", "Enumerator" then args[0] || Type::Combinator.untyped
681
+ when "Hash"
682
+ k = args[0] || Type::Combinator.untyped
683
+ v = args[1] || Type::Combinator.untyped
684
+ Type::Combinator.tuple_of(k, v)
685
+ else Type::Combinator.untyped
686
+ end
687
+ end
688
+
557
689
  # `a && b` / `a || b`. The LHS always runs, the RHS only
558
690
  # sometimes runs. Slice 6 phase 1 narrows the RHS evaluation:
559
691
  # `a && b` evaluates `b` under the truthy edge of `a`, and
@@ -819,23 +951,30 @@ module Rigor
819
951
  parts.join("::")
820
952
  end
821
953
 
822
- # v0.0.2 applies `RBS::Extended` `assert <target> is T`
823
- # directives to the post-call scope. The conditional
824
- # variants (`assert-if-true` / `assert-if-false`) are
825
- # NOT applied here — they refine the scope only when the
826
- # call is observed as a truthy / falsey predicate, which
827
- # `Narrowing.predicate_scopes` handles separately.
954
+ # Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") applies the
955
+ # post-return facts the merger produces for an
956
+ # `RBS::Extended`-annotated call. Reads through
957
+ # `RbsExtended.read_flow_contribution` so the bundle
958
+ # carries the canonical `Rigor::FlowContribution::Fact`
959
+ # rows for `:always` assert directives (the slice-4a
960
+ # routing places conditional asserts on `truthy_facts` /
961
+ # `falsey_facts`, which `Narrowing.predicate_scopes`
962
+ # consumes). Future plugin contributions that add
963
+ # `:always` assertions at the same call site flow through
964
+ # the same merger and land here.
828
965
  def apply_rbs_extended_assertions(call_node, current_scope)
829
966
  method_def = resolve_call_method(call_node, current_scope)
830
967
  return current_scope if method_def.nil?
831
968
 
832
- effects = RbsExtended.read_assert_effects(method_def)
833
- return current_scope if effects.empty?
969
+ contribution = RbsExtended.read_flow_contribution(method_def)
970
+ return current_scope if contribution.nil?
834
971
 
835
- effects.reduce(current_scope) do |scope_acc, effect|
836
- next scope_acc unless effect.always?
972
+ result = Rigor::FlowContribution::Merger.merge([contribution])
973
+ post_return = result.post_return_facts
974
+ return current_scope if post_return.empty?
837
975
 
838
- apply_assert_effect(effect, call_node, scope_acc, method_def)
976
+ post_return.reduce(current_scope) do |scope_acc, fact|
977
+ apply_post_return_fact(fact, call_node, scope_acc, method_def)
839
978
  end
840
979
  end
841
980
 
@@ -868,44 +1007,35 @@ module Rigor
868
1007
  end
869
1008
  end
870
1009
 
871
- def apply_assert_effect(effect, call_node, current_scope, method_def)
872
- target_node = assert_effect_target_node(effect, call_node, method_def)
1010
+ # Slice 4b-2 applies a single post-return Fact to the
1011
+ # scope. Mirrors `Narrowing#apply_fact_to_scope` (Fact
1012
+ # variant of the v0.0.2 `apply_assert_effect`); shares the
1013
+ # narrowing logic via `Narrowing.narrow_for_fact` so the
1014
+ # predicate / assert / plugin paths all converge on the
1015
+ # same hierarchy-aware narrowing rules.
1016
+ def apply_post_return_fact(fact, call_node, current_scope, method_def)
1017
+ target_node = fact_target_node(fact, call_node, method_def)
873
1018
  return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
874
1019
 
875
1020
  local_name = target_node.name
876
1021
  current_type = current_scope.local(local_name)
877
1022
  return current_scope if current_type.nil?
878
1023
 
879
- narrowed = narrow_for_assert_effect(current_type, effect, current_scope.environment)
1024
+ narrowed = Narrowing.narrow_for_fact(current_type, fact, current_scope.environment)
880
1025
  current_scope.with_local(local_name, narrowed)
881
1026
  end
882
1027
 
883
- # v0.0.2 #3 same `target: self` accommodation as
884
- # `Narrowing.effect_target_node`: the call's receiver
885
- # serves as the target for self-targeted directives.
886
- def assert_effect_target_node(effect, call_node, method_def)
887
- if effect.target_kind == :self
1028
+ # `:self` routes to the call receiver; otherwise we look
1029
+ # up the matching positional argument by parameter name.
1030
+ def fact_target_node(fact, call_node, method_def)
1031
+ if fact.target_kind == :self
888
1032
  call_node.receiver
889
1033
  else
890
- lookup_assert_arg(call_node, method_def, effect.target_name)
1034
+ lookup_post_return_arg(call_node, method_def, fact.target_name)
891
1035
  end
892
1036
  end
893
1037
 
894
- def narrow_for_assert_effect(current_type, effect, environment)
895
- if effect.refinement?
896
- return Narrowing.narrow_not_refinement(current_type, effect.refinement_type) if effect.negative?
897
-
898
- return effect.refinement_type
899
- end
900
-
901
- if effect.negative?
902
- Narrowing.narrow_not_class(current_type, effect.class_name, exact: false, environment: environment)
903
- else
904
- Narrowing.narrow_class(current_type, effect.class_name, exact: false, environment: environment)
905
- end
906
- end
907
-
908
- def lookup_assert_arg(call_node, method_def, target_name)
1038
+ def lookup_post_return_arg(call_node, method_def, target_name)
909
1039
  arguments = call_node.arguments&.arguments || []
910
1040
  method_def.method_types.each do |mt|
911
1041
  params = mt.type.required_positionals + mt.type.optional_positionals
@@ -1031,10 +1161,29 @@ module Rigor
1031
1161
  # bindings for every named block parameter on top. Parameter
1032
1162
  # types come from the receiving method's RBS signature when
1033
1163
  # one is available; the rest default to `Dynamic[Top]`.
1164
+ #
1165
+ # `;`-prefixed block-locals (`do |i; x|`) are bound to
1166
+ # `Constant[nil]` so the inner read shadows any outer
1167
+ # `x` per Ruby's semantics — at runtime the block-local is
1168
+ # a fresh nil-valued variable on every block invocation.
1169
+ # Without this shadow, an inner `x.even?` before the first
1170
+ # write would type-check against the OUTER `x` (e.g.
1171
+ # `Integer`) when the runtime would actually `NoMethodError`
1172
+ # on `nil`.
1034
1173
  def build_block_entry_scope(call_node, block_node)
1035
1174
  expected = expected_block_param_types_for(call_node)
1036
1175
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
1037
- bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
1176
+ scope_with_params = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
1177
+ block_local_names(block_node).reduce(scope_with_params) do |acc, name|
1178
+ acc.with_local(name, Type::Combinator.constant_of(nil))
1179
+ end
1180
+ end
1181
+
1182
+ def block_local_names(block_node)
1183
+ params_root = block_node.parameters
1184
+ return [] unless params_root.is_a?(Prism::BlockParametersNode)
1185
+
1186
+ params_root.locals.map(&:name)
1038
1187
  end
1039
1188
 
1040
1189
  def expected_block_param_types_for(call_node)
@@ -1318,6 +1467,197 @@ module Rigor
1318
1467
  def reduce_scopes_with_nil_injection(scopes)
1319
1468
  scopes.reduce { |a, b| join_with_nil_injection(a, b) }
1320
1469
  end
1470
+
1471
+ # ---------------------------------------------------------------
1472
+ # rescue variable binding helpers
1473
+ # ---------------------------------------------------------------
1474
+
1475
+ # Returns `scope` extended with the rescue reference variable bound
1476
+ # to the exception instance type. Leaves scope unchanged when the
1477
+ # node carries no reference (bare `rescue` without `=> var`).
1478
+ def bind_rescue_reference(rescue_node, scope)
1479
+ ref = rescue_node.reference
1480
+ return scope unless ref.is_a?(Prism::LocalVariableTargetNode)
1481
+
1482
+ scope.with_local(ref.name, rescue_exception_type(rescue_node, scope))
1483
+ end
1484
+
1485
+ # Derives the exception instance type for a `RescueNode`. When the
1486
+ # exceptions list is empty (bare `rescue`) the type is
1487
+ # `StandardError`. When one or more exception classes are named the
1488
+ # types are unioned. Falls back to `StandardError` for any class
1489
+ # that cannot be resolved to a `Singleton` type.
1490
+ def rescue_exception_type(rescue_node, scope)
1491
+ exceptions = rescue_node.exceptions
1492
+ if exceptions.empty?
1493
+ Type::Combinator.nominal_of("StandardError")
1494
+ else
1495
+ types = exceptions.map do |exc_node|
1496
+ singleton_type, = sub_eval(exc_node, scope)
1497
+ singleton_to_nominal(singleton_type)
1498
+ end
1499
+ Type::Combinator.union(*types)
1500
+ end
1501
+ end
1502
+
1503
+ # ---------------------------------------------------------------
1504
+ # `case/in` pattern variable binding helpers
1505
+ # ---------------------------------------------------------------
1506
+
1507
+ # Builds the entry scope for an `in` branch by injecting every
1508
+ # variable captured by the pattern as a local binding.
1509
+ def apply_in_pattern_bindings(subject, pattern, scope)
1510
+ bindings = collect_in_pattern_bindings(subject, pattern, scope)
1511
+ bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
1512
+ end
1513
+
1514
+ # Returns an array of `[Symbol, Rigor::Type]` pairs for every
1515
+ # variable captured by `pattern`. Unrecognised pattern nodes
1516
+ # contribute no bindings (fail-soft).
1517
+ # rubocop:disable Metrics/CyclomaticComplexity
1518
+ def collect_in_pattern_bindings(subject, pattern, scope)
1519
+ case pattern
1520
+ when Prism::CapturePatternNode
1521
+ [[pattern.target.name, pattern_capture_type(pattern.value, scope)]]
1522
+ when Prism::LocalVariableTargetNode
1523
+ subject_type = subject.is_a?(Prism::LocalVariableReadNode) ? scope.local(subject.name) : nil
1524
+ [[pattern.name, subject_type || Type::Combinator.untyped]]
1525
+ when Prism::ImplicitNode
1526
+ collect_in_pattern_bindings(subject, pattern.value, scope)
1527
+ when Prism::ArrayPatternNode
1528
+ collect_array_pattern_bindings(pattern, scope)
1529
+ when Prism::FindPatternNode
1530
+ collect_find_pattern_bindings(pattern, scope)
1531
+ when Prism::HashPatternNode
1532
+ collect_hash_pattern_bindings(pattern, scope)
1533
+ when Prism::AlternationPatternNode
1534
+ collect_alternation_pattern_bindings(subject, pattern, scope)
1535
+ else
1536
+ []
1537
+ end
1538
+ end
1539
+ # rubocop:enable Metrics/CyclomaticComplexity
1540
+
1541
+ def collect_array_pattern_bindings(pattern, scope)
1542
+ bindings = [*pattern.requireds, *pattern.posts].flat_map do |elem|
1543
+ collect_in_pattern_bindings(nil, elem, scope)
1544
+ end
1545
+ append_array_splat_binding(bindings, pattern.rest)
1546
+ bindings
1547
+ end
1548
+
1549
+ def collect_hash_pattern_bindings(pattern, scope)
1550
+ bindings = pattern.elements.flat_map do |assoc|
1551
+ next [] unless assoc.is_a?(Prism::AssocNode) && assoc.value
1552
+
1553
+ collect_in_pattern_bindings(nil, assoc.value, scope)
1554
+ end
1555
+ rest = pattern.rest
1556
+ if rest.is_a?(Prism::AssocSplatNode)
1557
+ val = rest.value
1558
+ bindings << [val.name, hash_pattern_rest_type] if val.is_a?(Prism::LocalVariableTargetNode)
1559
+ end
1560
+ bindings
1561
+ end
1562
+
1563
+ def collect_find_pattern_bindings(pattern, scope)
1564
+ bindings = pattern.requireds.flat_map do |elem|
1565
+ collect_in_pattern_bindings(nil, elem, scope)
1566
+ end
1567
+ [pattern.left, pattern.right].each { |splat| append_array_splat_binding(bindings, splat) }
1568
+ bindings
1569
+ end
1570
+
1571
+ # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of
1572
+ # the unmatched elements; bind `rest` to `Array[untyped]` rather
1573
+ # than the previous bare `untyped`. Per-element typing waits on
1574
+ # subject-aware element-type extraction (the binder doesn't see
1575
+ # the case subject).
1576
+ def append_array_splat_binding(bindings, splat)
1577
+ return unless splat.is_a?(Prism::SplatNode)
1578
+
1579
+ target = splat.expression
1580
+ return unless target.is_a?(Prism::LocalVariableTargetNode)
1581
+
1582
+ bindings << [target.name, Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
1583
+ end
1584
+
1585
+ # `{ key:, **rest }` binds `rest` to a Hash whose keys are
1586
+ # Symbols (the only legal key shape for a hash pattern) and
1587
+ # whose values are untyped (the binder can't see the subject's
1588
+ # value type).
1589
+ def hash_pattern_rest_type
1590
+ Type::Combinator.nominal_of(
1591
+ "Hash",
1592
+ type_args: [Type::Combinator.nominal_of("Symbol"), Type::Combinator.untyped]
1593
+ )
1594
+ end
1595
+
1596
+ # ---------------------------------------------------------------
1597
+ # named-capture regex binding (`MatchWriteNode`)
1598
+ # ---------------------------------------------------------------
1599
+
1600
+ # `/(?<year>\d+)/ =~ str` — Prism emits a `MatchWriteNode` that
1601
+ # wraps the `=~` call and lists the named-capture targets. Each
1602
+ # target is bound to `String | nil` (the capture is absent as nil
1603
+ # when the pattern doesn't match or the group didn't participate).
1604
+ def eval_match_write(node)
1605
+ match_type, post_scope = sub_eval(node.call, scope)
1606
+ string_or_nil = Type::Combinator.union(
1607
+ Type::Combinator.nominal_of("String"),
1608
+ Type::Combinator.constant_of(nil)
1609
+ )
1610
+ bound_scope = node.targets.reduce(post_scope) do |s, target|
1611
+ next s unless target.is_a?(Prism::LocalVariableTargetNode)
1612
+
1613
+ s.with_local(target.name, string_or_nil)
1614
+ end
1615
+ [match_type, bound_scope]
1616
+ end
1617
+
1618
+ # ---------------------------------------------------------------
1619
+ # shared type conversion helper
1620
+ # ---------------------------------------------------------------
1621
+
1622
+ # Converts a `Singleton[ClassName]` (the class object) to the
1623
+ # corresponding `Nominal[ClassName]` (an instance). Falls back to
1624
+ # `untyped` for carriers that are not Singleton (e.g. Dynamic[Top]
1625
+ # when the class could not be resolved).
1626
+ def singleton_to_nominal(type)
1627
+ type.is_a?(Type::Singleton) ? Type::Combinator.nominal_of(type.class_name) : Type::Combinator.untyped
1628
+ end
1629
+
1630
+ # Returns the type to bind for a `CapturePatternNode`'s target.
1631
+ # Plain class references collapse to the matching `Nominal[T]`;
1632
+ # `AlternationPatternNode` (`Integer | String => x`) unions every
1633
+ # alternate's resolved type. Anything else falls back to
1634
+ # `untyped` (the conservative legacy behaviour).
1635
+ def pattern_capture_type(value_node, scope)
1636
+ if value_node.is_a?(Prism::AlternationPatternNode)
1637
+ left = pattern_capture_type(value_node.left, scope)
1638
+ right = pattern_capture_type(value_node.right, scope)
1639
+ Type::Combinator.union(left, right)
1640
+ else
1641
+ singleton_to_nominal(sub_eval(value_node, scope).first)
1642
+ end
1643
+ end
1644
+
1645
+ # `in PatternA | PatternB` — Ruby requires both alternates to
1646
+ # bind the same names, but the binder runs against the AST and
1647
+ # cannot enforce that. We collect bindings from each side and
1648
+ # merge by name, unioning types when both alternates contribute.
1649
+ # Names that only one alternate contributes still surface (the
1650
+ # parser would have rejected the case at compile time, so by the
1651
+ # time we see it the user's intent is the merged set).
1652
+ def collect_alternation_pattern_bindings(subject, pattern, scope)
1653
+ left = collect_in_pattern_bindings(subject, pattern.left, scope)
1654
+ right = collect_in_pattern_bindings(subject, pattern.right, scope)
1655
+ merged = {}
1656
+ (left + right).each do |name, type|
1657
+ merged[name] = merged.key?(name) ? Type::Combinator.union(merged[name], type) : type
1658
+ end
1659
+ merged.to_a
1660
+ end
1321
1661
  end
1322
1662
  # rubocop:enable Metrics/ClassLength
1323
1663
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Raised when a plugin's I/O attempt fails the active
6
+ # {TrustPolicy}. Surfaced through {IoBoundary} so the analyzer
7
+ # can convert the exception into a `:plugin_loader` diagnostic
8
+ # without crashing `rigor check`.
9
+ #
10
+ # The policy is documented in [ADR-2 § "Plugin Trust and I/O
11
+ # Policy"](../../../docs/adr/2-extension-api.md). Slice 2's
12
+ # surface lists `:read_outside_scope` and `:network_disabled`
13
+ # as the two reason codes; future slices may extend the set.
14
+ class AccessDeniedError < StandardError
15
+ attr_reader :reason, :resource
16
+
17
+ def initialize(message, reason:, resource: nil)
18
+ super(message)
19
+ @reason = reason
20
+ @resource = resource
21
+ end
22
+ end
23
+ end
24
+ end