rigortype 0.0.1 → 0.0.3

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/data/builtins/ruby_core/array.yml +1470 -0
  3. data/data/builtins/ruby_core/file.yml +501 -0
  4. data/data/builtins/ruby_core/io.yml +1594 -0
  5. data/data/builtins/ruby_core/numeric.yml +1809 -0
  6. data/data/builtins/ruby_core/string.yml +1850 -0
  7. data/lib/rigor/analysis/check_rules.rb +297 -5
  8. data/lib/rigor/analysis/diagnostic.rb +13 -2
  9. data/lib/rigor/analysis/runner.rb +52 -5
  10. data/lib/rigor/builtins/imported_refinements.rb +69 -0
  11. data/lib/rigor/cli/type_of_command.rb +11 -5
  12. data/lib/rigor/cli/type_scan_command.rb +13 -8
  13. data/lib/rigor/cli.rb +26 -6
  14. data/lib/rigor/configuration.rb +18 -2
  15. data/lib/rigor/environment.rb +3 -1
  16. data/lib/rigor/inference/acceptance.rb +180 -0
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  19. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  20. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  21. data/lib/rigor/inference/expression_typer.rb +151 -0
  22. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
  23. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  24. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  26. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
  27. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  28. data/lib/rigor/inference/narrowing.rb +471 -10
  29. data/lib/rigor/inference/scope_indexer.rb +66 -0
  30. data/lib/rigor/inference/statement_evaluator.rb +305 -2
  31. data/lib/rigor/rbs_extended.rb +174 -14
  32. data/lib/rigor/scope.rb +44 -5
  33. data/lib/rigor/type/combinator.rb +69 -1
  34. data/lib/rigor/type/difference.rb +155 -0
  35. data/lib/rigor/type/integer_range.rb +137 -0
  36. data/lib/rigor/type.rb +2 -0
  37. data/lib/rigor/version.rb +1 -1
  38. data/sig/rigor/inference.rbs +5 -2
  39. data/sig/rigor/rbs_extended.rbs +25 -1
  40. data/sig/rigor/scope.rbs +4 -0
  41. data/sig/rigor/type.rbs +51 -1
  42. metadata +15 -1
@@ -41,6 +41,30 @@ module Rigor
41
41
  # the first preview; later slices broaden it.
42
42
  # rubocop:disable Metrics/ModuleLength
43
43
  module CheckRules
44
+ # Stable identifiers for each rule. Used by the
45
+ # configuration `disable:` list and the in-source
46
+ # `# rigor:disable <rule>` suppression comment system
47
+ # to identify diagnostics by category. Rule identifiers
48
+ # are kebab-case strings; new rules MUST register here
49
+ # so user configuration can refer to them.
50
+ RULE_UNDEFINED_METHOD = "undefined-method"
51
+ RULE_WRONG_ARITY = "wrong-arity"
52
+ RULE_ARGUMENT_TYPE = "argument-type-mismatch"
53
+ RULE_NIL_RECEIVER = "possible-nil-receiver"
54
+ RULE_DUMP_TYPE = "dump-type"
55
+ RULE_ASSERT_TYPE = "assert-type"
56
+ RULE_ALWAYS_RAISES = "always-raises"
57
+
58
+ ALL_RULES = [
59
+ RULE_UNDEFINED_METHOD,
60
+ RULE_WRONG_ARITY,
61
+ RULE_ARGUMENT_TYPE,
62
+ RULE_NIL_RECEIVER,
63
+ RULE_DUMP_TYPE,
64
+ RULE_ASSERT_TYPE,
65
+ RULE_ALWAYS_RAISES
66
+ ].freeze
67
+
44
68
  module_function
45
69
 
46
70
  # Yields diagnostics for every unrecognised method call on
@@ -53,7 +77,7 @@ module Rigor
53
77
  # @param root [Prism::Node]
54
78
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
55
79
  # @return [Array<Rigor::Analysis::Diagnostic>]
56
- def diagnose(path:, root:, scope_index:) # rubocop:disable Metrics/CyclomaticComplexity
80
+ def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
57
81
  diagnostics = []
58
82
  Source::NodeWalker.each(root) do |node|
59
83
  next unless node.is_a?(Prism::CallNode)
@@ -64,6 +88,9 @@ module Rigor
64
88
  arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
65
89
  diagnostics << arity_diagnostic if arity_diagnostic
66
90
 
91
+ arg_type_diagnostic = argument_type_diagnostic(path, node, scope_index)
92
+ diagnostics << arg_type_diagnostic if arg_type_diagnostic
93
+
67
94
  nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
68
95
  diagnostics << nil_diagnostic if nil_diagnostic
69
96
 
@@ -72,8 +99,57 @@ module Rigor
72
99
 
73
100
  assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
74
101
  diagnostics << assert_diagnostic if assert_diagnostic
102
+
103
+ raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
104
+ diagnostics << raises_diagnostic if raises_diagnostic
105
+ end
106
+ filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
107
+ end
108
+
109
+ # v0.0.2 #6 — diagnostic suppression. Two kinds of
110
+ # suppression compose:
111
+ #
112
+ # - **Project-level**: `disabled_rules` is the
113
+ # project's `.rigor.yml` `disable:` list. Any
114
+ # diagnostic whose `rule` is in the list is dropped.
115
+ # - **In-source**: `# rigor:disable <rule1>, <rule2>`
116
+ # on the same line as the offending expression
117
+ # suppresses the matching diagnostic for that line
118
+ # only. `# rigor:disable all` on a line suppresses
119
+ # every rule on that line.
120
+ #
121
+ # Diagnostics with `rule == nil` (parse errors, path
122
+ # errors, internal analyzer errors) are NEVER
123
+ # suppressed — they represent failures the user cannot
124
+ # silence away.
125
+ def filter_suppressed(diagnostics, comments:, disabled_rules:)
126
+ suppressions = parse_suppression_comments(comments)
127
+ disabled = disabled_rules.to_set(&:to_s)
128
+
129
+ diagnostics.reject do |diagnostic|
130
+ rule = diagnostic.rule
131
+ next false if rule.nil?
132
+ next true if disabled.include?(rule)
133
+
134
+ line_rules = suppressions[diagnostic.line]
135
+ line_rules && (line_rules.include?("all") || line_rules.include?(rule))
75
136
  end
76
- diagnostics
137
+ end
138
+
139
+ SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w,\s-]+)/
140
+ private_constant :SUPPRESSION_PATTERN
141
+
142
+ def parse_suppression_comments(comments)
143
+ result = Hash.new { |h, k| h[k] = Set.new }
144
+ comments.each do |comment|
145
+ source = comment.location.slice
146
+ match = SUPPRESSION_PATTERN.match(source)
147
+ next if match.nil?
148
+
149
+ rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
150
+ rules.each { |rule| result[comment.location.start_line] << rule }
151
+ end
152
+ result
77
153
  end
78
154
 
79
155
  # rubocop:disable Metrics/ClassLength
@@ -262,6 +338,13 @@ module Rigor
262
338
  end
263
339
 
264
340
  def arity_eligible?(function)
341
+ # `RBS::Types::UntypedFunction` (used for `(?) ->`
342
+ # untyped sigs) does not expose the per-arity
343
+ # accessors. Treating it as ineligible is the
344
+ # correct conservative move: an untyped function
345
+ # has no static arity to enforce.
346
+ return false unless function.respond_to?(:required_keywords)
347
+
265
348
  function.required_keywords.empty? && function.trailing_positionals.empty?
266
349
  end
267
350
 
@@ -361,13 +444,14 @@ module Rigor
361
444
  # The diagnostic does NOT count toward `Result#error_count`
362
445
  # so a fixture peppered with `dump_type` calls still
363
446
  # passes `rigor check`.
364
- def dump_type_diagnostic(path, call_node, scope_index)
447
+ def dump_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
365
448
  return nil unless rigor_testing_call?(call_node, :dump_type)
366
449
  return nil if call_node.arguments.nil? || call_node.arguments.arguments.empty?
367
450
 
368
451
  arg = call_node.arguments.arguments.first
369
452
  scope = scope_index[arg] || scope_index[call_node]
370
453
  return nil if scope.nil?
454
+ return nil if inside_rigor_testing?(scope)
371
455
 
372
456
  type = scope.type_of(arg)
373
457
  location = call_node.message_loc || call_node.location
@@ -376,7 +460,8 @@ module Rigor
376
460
  line: location.start_line,
377
461
  column: location.start_column + 1,
378
462
  message: "dump_type: #{type.describe(:short)}",
379
- severity: :info
463
+ severity: :info,
464
+ rule: RULE_DUMP_TYPE
380
465
  )
381
466
  end
382
467
 
@@ -388,7 +473,7 @@ module Rigor
388
473
  # is emitted; matching calls produce no output. This
389
474
  # lets a fixture document its expected types inline:
390
475
  # subsequent `rigor check` runs flag any drift.
391
- def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
476
+ def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
392
477
  return nil unless rigor_testing_call?(call_node, :assert_type)
393
478
  return nil if call_node.arguments.nil? || call_node.arguments.arguments.size < 2
394
479
 
@@ -398,6 +483,7 @@ module Rigor
398
483
  value_node = call_node.arguments.arguments[1]
399
484
  scope = scope_index[value_node] || scope_index[call_node]
400
485
  return nil if scope.nil?
486
+ return nil if inside_rigor_testing?(scope)
401
487
 
402
488
  actual = scope.type_of(value_node).describe(:short)
403
489
  expected = expected_node.unescaped.to_s
@@ -419,6 +505,24 @@ module Rigor
419
505
  RIGOR_TESTING_RECEIVERS = ["Rigor", "Rigor::Testing", "Testing"].freeze
420
506
  private_constant :RIGOR_TESTING_RECEIVERS
421
507
 
508
+ # The dump/assert helpers' own implementation methods
509
+ # call back into `Testing.dump_type` / `assert_type` to
510
+ # share the no-op runtime stub. We do NOT want those
511
+ # internal calls to surface diagnostics — they are
512
+ # reflexive plumbing, not user assertions. This filter
513
+ # skips diagnostics when the call site's `self_type` is
514
+ # the `Rigor` or `Rigor::Testing` module itself.
515
+ SELF_REFERENTIAL_SCOPES = ["Rigor", "Rigor::Testing"].freeze
516
+ private_constant :SELF_REFERENTIAL_SCOPES
517
+
518
+ def inside_rigor_testing?(scope)
519
+ self_type = scope.self_type
520
+ return false if self_type.nil?
521
+ return false unless self_type.respond_to?(:class_name)
522
+
523
+ SELF_REFERENTIAL_SCOPES.include?(self_type.class_name)
524
+ end
525
+
422
526
  def rigor_testing_call?(call_node, method_name)
423
527
  return false unless call_node.name == method_name
424
528
 
@@ -449,6 +553,7 @@ module Rigor
449
553
  def build_assert_type_diagnostic(path, call_node, expected, actual)
450
554
  location = call_node.message_loc || call_node.location
451
555
  Diagnostic.new(
556
+ rule: RULE_ASSERT_TYPE,
452
557
  path: path,
453
558
  line: location.start_line,
454
559
  column: location.start_column + 1,
@@ -460,6 +565,7 @@ module Rigor
460
565
  def build_nil_receiver_diagnostic(path, call_node)
461
566
  location = call_node.message_loc || call_node.location
462
567
  Diagnostic.new(
568
+ rule: RULE_NIL_RECEIVER,
463
569
  path: path,
464
570
  line: location.start_line,
465
571
  column: location.start_column + 1,
@@ -468,6 +574,190 @@ module Rigor
468
574
  )
469
575
  end
470
576
 
577
+ # Diagnoses calls that the analyzer can prove will always
578
+ # raise. Today the only triggering shape is integer
579
+ # division/modulo by a literal zero divisor:
580
+ #
581
+ # 5 / 0 # => ZeroDivisionError
582
+ # x.modulo(0) # => ZeroDivisionError when x: Integer
583
+ # xs.size % 0 # same — non_negative_int / Constant[0]
584
+ #
585
+ # Float divmod by zero returns Infinity/NaN at runtime, so
586
+ # the rule restricts to Integer-rooted receivers (`Constant`,
587
+ # `IntegerRange`, `Nominal[Integer]`). The argument MUST be a
588
+ # `Constant<Integer>` whose value is exactly zero — a
589
+ # `Union[Constant[0], Constant[2]]` divisor "may" raise,
590
+ # which we surface separately (future slice).
591
+ INTEGER_RAISING_OPERATORS = %i[/ % div modulo divmod].freeze
592
+ private_constant :INTEGER_RAISING_OPERATORS
593
+
594
+ def always_raises_diagnostic(path, call_node, scope_index)
595
+ return nil unless integer_zero_division?(call_node, scope_index)
596
+
597
+ build_always_raises_diagnostic(path, call_node)
598
+ end
599
+
600
+ def integer_zero_division?(call_node, scope_index)
601
+ return false unless raising_call_shape?(call_node)
602
+
603
+ scope = scope_index[call_node]
604
+ return false if scope.nil?
605
+ return false unless integer_rooted_for_diagnostic?(scope.type_of(call_node.receiver))
606
+
607
+ arg = single_argument(call_node)
608
+ arg && integer_zero_constant?(scope.type_of(arg))
609
+ end
610
+
611
+ def raising_call_shape?(call_node)
612
+ !call_node.receiver.nil? && INTEGER_RAISING_OPERATORS.include?(call_node.name)
613
+ end
614
+
615
+ def single_argument(call_node)
616
+ args = call_node.arguments&.arguments || []
617
+ args.size == 1 ? args.first : nil
618
+ end
619
+
620
+ def integer_rooted_for_diagnostic?(type)
621
+ case type
622
+ when Type::Constant then type.value.is_a?(Integer)
623
+ when Type::IntegerRange then true
624
+ when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
625
+ else false
626
+ end
627
+ end
628
+
629
+ def integer_zero_constant?(type)
630
+ type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.zero?
631
+ end
632
+
633
+ def build_always_raises_diagnostic(path, call_node)
634
+ location = call_node.message_loc || call_node.location
635
+ Diagnostic.new(
636
+ rule: RULE_ALWAYS_RAISES,
637
+ path: path,
638
+ line: location.start_line,
639
+ column: location.start_column + 1,
640
+ message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
641
+ severity: :error
642
+ )
643
+ end
644
+
645
+ # v0.0.2 #4 — argument-type-mismatch diagnostic.
646
+ # Walks a call's positional arguments and checks each
647
+ # against the matching parameter's RBS type via
648
+ # `Rigor::Inference::Acceptance`. Emits an `:error`
649
+ # for the first argument whose type the parameter
650
+ # does NOT accept under the gradual mode.
651
+ #
652
+ # Conservative envelope (matches the wrong-arity rule
653
+ # plus a few additional skips):
654
+ # - Receiver must be Nominal / Singleton / Constant
655
+ # (the same `concrete_class_name` test).
656
+ # - Method must be in RBS.
657
+ # - Method must have exactly ONE method type
658
+ # (overload). Multi-overload checking is left for
659
+ # a follow-up because picking the "intended"
660
+ # overload requires the dispatcher's full
661
+ # acceptance plumbing.
662
+ # - The selected overload must have NO
663
+ # rest_positionals, NO required keywords, NO
664
+ # trailing positionals.
665
+ # - The call must use plain positional arguments
666
+ # (no splat / kw / block-pass / forwarded).
667
+ # - Per-argument: skip when EITHER side is `Dynamic`
668
+ # (the call cannot be statically refuted).
669
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
670
+ def argument_type_diagnostic(path, call_node, scope_index)
671
+ return nil if call_node.receiver.nil?
672
+ return nil unless plain_positional_call?(call_node)
673
+
674
+ scope = scope_index[call_node]
675
+ return nil if scope.nil?
676
+
677
+ receiver_type = scope.type_of(call_node.receiver)
678
+ class_name = concrete_class_name(receiver_type)
679
+ return nil if class_name.nil?
680
+
681
+ # NOTE: unlike the undefined-method / wrong-arity
682
+ # rules, we deliberately do NOT skip when
683
+ # `discovered_method?` matches. When the user
684
+ # supplies BOTH a `def` and an RBS sig, the sig is
685
+ # the authoritative parameter contract and we
686
+ # should validate calls against it.
687
+ loader = scope.environment.rbs_loader
688
+ return nil if loader.nil?
689
+ return nil unless loader.class_known?(class_name)
690
+ return nil unless definition_available?(loader, receiver_type, class_name)
691
+
692
+ method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
693
+ return nil if method_def.nil? || method_def == true
694
+ return nil unless method_def.method_types.size == 1
695
+
696
+ mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope)
697
+ return nil if mismatch.nil?
698
+
699
+ build_argument_type_diagnostic(path, call_node, class_name, mismatch)
700
+ end
701
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
702
+
703
+ def first_argument_mismatch(method_type, call_node, scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
704
+ function = method_type.type
705
+ return nil unless argument_check_eligible?(function)
706
+
707
+ params = function.required_positionals + function.optional_positionals
708
+ arguments = call_node.arguments&.arguments || []
709
+ arguments.each_with_index do |arg, index|
710
+ param = params[index]
711
+ next if param.nil? # arity mismatch is the wrong-arity rule's concern.
712
+
713
+ param_type = translate_param_type(param.type, scope.environment)
714
+ next if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
715
+
716
+ arg_type = scope.type_of(arg)
717
+ next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
718
+
719
+ result = Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual)
720
+ return { node: arg, name: param.name, expected: param_type, actual: arg_type } if result.no?
721
+ end
722
+ nil
723
+ end
724
+
725
+ def argument_check_eligible?(function)
726
+ # See `arity_eligible?`: `UntypedFunction` lacks
727
+ # the per-arity accessors. Treat it as ineligible
728
+ # for argument-type-mismatch diagnostics.
729
+ return false unless function.respond_to?(:required_keywords)
730
+
731
+ function.rest_positionals.nil? &&
732
+ function.required_keywords.empty? &&
733
+ function.optional_keywords.empty? &&
734
+ function.rest_keywords.nil? &&
735
+ function.trailing_positionals.empty?
736
+ end
737
+
738
+ def translate_param_type(rbs_type, _environment)
739
+ Inference::RbsTypeTranslator.translate(rbs_type)
740
+ rescue StandardError
741
+ Type::Combinator.untyped
742
+ end
743
+
744
+ def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
745
+ location = mismatch[:node].location
746
+ method_label = "`#{call_node.name}' on #{class_name}"
747
+ parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
748
+ message = "argument type mismatch at #{parameter_label}: " \
749
+ "expected #{mismatch[:expected].describe(:short)}, " \
750
+ "got #{mismatch[:actual].describe(:short)}"
751
+ Diagnostic.new(
752
+ rule: RULE_ARGUMENT_TYPE,
753
+ path: path,
754
+ line: location.start_line,
755
+ column: location.start_column + 1,
756
+ message: message,
757
+ severity: :error
758
+ )
759
+ end
760
+
471
761
  # rubocop:disable Metrics/ParameterLists
472
762
  def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
473
763
  location = call_node.message_loc || call_node.location
@@ -475,6 +765,7 @@ module Rigor
475
765
  method_label = "`#{call_node.name}' on #{class_name}"
476
766
  message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
477
767
  Diagnostic.new(
768
+ rule: RULE_WRONG_ARITY,
478
769
  path: path,
479
770
  line: location.start_line,
480
771
  column: location.start_column + 1,
@@ -488,6 +779,7 @@ module Rigor
488
779
  location = call_node.message_loc || call_node.location
489
780
  rendered_receiver = receiver_type.describe
490
781
  Diagnostic.new(
782
+ rule: RULE_UNDEFINED_METHOD,
491
783
  path: path,
492
784
  line: location.start_line,
493
785
  column: location.start_column + 1,
@@ -3,14 +3,24 @@
3
3
  module Rigor
4
4
  module Analysis
5
5
  class Diagnostic
6
- attr_reader :path, :line, :column, :message, :severity
6
+ attr_reader :path, :line, :column, :message, :severity, :rule
7
7
 
8
- def initialize(path:, line:, column:, message:, severity: :error)
8
+ # `rule:` is the stable identifier (a kebab-case string)
9
+ # of the diagnostic's source rule. It is used by the
10
+ # configuration and the in-source `# rigor:disable <rule>`
11
+ # suppression comment system to identify diagnostics by
12
+ # category. Diagnostics not produced by `CheckRules`
13
+ # (parse errors, path errors, internal analyzer errors)
14
+ # may leave `rule` as nil and stay unsuppressible.
15
+ # rubocop:disable Metrics/ParameterLists
16
+ def initialize(path:, line:, column:, message:, severity: :error, rule: nil)
17
+ # rubocop:enable Metrics/ParameterLists
9
18
  @path = path
10
19
  @line = line
11
20
  @column = column
12
21
  @message = message
13
22
  @severity = severity
23
+ @rule = rule
14
24
  end
15
25
 
16
26
  def error?
@@ -23,6 +33,7 @@ module Rigor
23
33
  "line" => line,
24
34
  "column" => column,
25
35
  "severity" => severity.to_s,
36
+ "rule" => rule,
26
37
  "message" => message
27
38
  }
28
39
  end
@@ -4,18 +4,21 @@ require "prism"
4
4
 
5
5
  require_relative "../environment"
6
6
  require_relative "../scope"
7
+ require_relative "../inference/coverage_scanner"
7
8
  require_relative "../inference/scope_indexer"
9
+ require_relative "../inference/method_dispatcher/file_folding"
8
10
  require_relative "check_rules"
9
11
  require_relative "diagnostic"
10
12
  require_relative "result"
11
13
 
12
14
  module Rigor
13
15
  module Analysis
14
- class Runner
16
+ class Runner # rubocop:disable Metrics/ClassLength
15
17
  RUBY_GLOB = "**/*.rb"
16
18
 
17
- def initialize(configuration:)
19
+ def initialize(configuration:, explain: false)
18
20
  @configuration = configuration
21
+ @explain = explain
19
22
  end
20
23
 
21
24
  # Walks every Ruby file under `paths`, parses it, builds a
@@ -27,7 +30,13 @@ module Rigor
27
30
  # is built once at run start through `Environment.for_project`
28
31
  # so all files share the same RBS load.
29
32
  def run(paths = @configuration.paths)
30
- environment = Environment.for_project
33
+ Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
34
+ @configuration.fold_platform_specific_paths
35
+
36
+ environment = Environment.for_project(
37
+ libraries: @configuration.libraries,
38
+ signature_paths: @configuration.signature_paths
39
+ )
31
40
  expansion = expand_paths(paths)
32
41
 
33
42
  diagnostics = expansion.fetch(:errors)
@@ -73,13 +82,20 @@ module Rigor
73
82
  )
74
83
  end
75
84
 
76
- def analyze_file(path, environment)
85
+ def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
77
86
  parse_result = Prism.parse_file(path)
78
87
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
79
88
 
80
89
  scope = Scope.empty(environment: environment)
81
90
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
82
- CheckRules.diagnose(path: path, root: parse_result.value, scope_index: index)
91
+ diagnostics = CheckRules.diagnose(
92
+ path: path,
93
+ root: parse_result.value,
94
+ scope_index: index,
95
+ comments: parse_result.comments,
96
+ disabled_rules: @configuration.disabled_rules
97
+ )
98
+ diagnostics + explain_diagnostics(path, parse_result.value, scope)
83
99
  rescue Errno::ENOENT => e
84
100
  [
85
101
  Diagnostic.new(
@@ -102,6 +118,37 @@ module Rigor
102
118
  ]
103
119
  end
104
120
 
121
+ # v0.0.2 #10 — fail-soft fallback explanation. When
122
+ # `--explain` is set the runner additionally walks the
123
+ # file with `Rigor::Inference::CoverageScanner` and emits
124
+ # one `:info` diagnostic per directly-unrecognized node,
125
+ # naming the node class and the type the engine fell back
126
+ # to. The CoverageScanner is the canonical "first-event-
127
+ # per-node" probe: it already filters out pass-through
128
+ # wrappers (`ProgramNode`, `StatementsNode`,
129
+ # `ParenthesesNode`) so the explain stream is attributable
130
+ # to the leaf node that actually triggered the fallback.
131
+ def explain_diagnostics(path, root, scope)
132
+ return [] unless @explain
133
+
134
+ result = Inference::CoverageScanner.new(scope: scope).scan(root)
135
+ result.events.map { |event| explain_diagnostic(path, event) }
136
+ end
137
+
138
+ def explain_diagnostic(path, event)
139
+ location = event.location
140
+ line = location ? location.start_line : 1
141
+ column = location ? location.start_column + 1 : 1
142
+ Diagnostic.new(
143
+ path: path,
144
+ line: line,
145
+ column: column,
146
+ message: "fail-soft fallback at #{event.node_class}: #{event.inner_type.describe(:short)}",
147
+ severity: :info,
148
+ rule: "fallback"
149
+ )
150
+ end
151
+
105
152
  def parse_diagnostics(path, parse_result)
106
153
  parse_result.errors.map do |error|
107
154
  location = error.location
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Canonical-name registry for the imported-built-in
8
+ # refinement catalogue. See `imported-built-in-types.md`
9
+ # in `docs/type-specification/` for the full catalogue
10
+ # rationale and the kebab-case naming rule.
11
+ #
12
+ # Maps kebab-case names (`non-empty-string`, `positive-int`,
13
+ # `non-empty-array`, …) to the Rigor type each name denotes.
14
+ # The registry is the single integration point for:
15
+ #
16
+ # - The new `rigor:v1:return:` RBS::Extended directive
17
+ # ([`Rigor::RbsExtended.read_return_type_override`](../rbs_extended.rb)),
18
+ # which overrides a method's RBS-declared return type
19
+ # with a refinement carrier.
20
+ # - Future `RBS::Extended` directives that accept a
21
+ # refinement name in any type position (`param:`,
22
+ # `assert: x is non-empty-string`, …).
23
+ # - The display side: `Type::Difference#describe` already
24
+ # recognises the same shapes and prints the kebab-case
25
+ # spelling without consulting the registry.
26
+ #
27
+ # Names not in the registry resolve to `nil`; callers
28
+ # decide whether to fall back to the RBS-declared type or
29
+ # raise a parse error.
30
+ #
31
+ # The current registry covers no-argument refinement
32
+ # names. Parameterised refinements like
33
+ # `non-empty-array[Integer]` will be parsed by a future
34
+ # tokeniser; today the no-arg form `non-empty-array` lands
35
+ # at `non_empty_array(top)` and downstream code projects
36
+ # to the underlying base nominal.
37
+ module ImportedRefinements
38
+ REGISTRY = {
39
+ "non-empty-string" => -> { Type::Combinator.non_empty_string },
40
+ "non-zero-int" => -> { Type::Combinator.non_zero_int },
41
+ "non-empty-array" => -> { Type::Combinator.non_empty_array },
42
+ "non-empty-hash" => -> { Type::Combinator.non_empty_hash },
43
+ "positive-int" => -> { Type::Combinator.positive_int },
44
+ "non-negative-int" => -> { Type::Combinator.non_negative_int },
45
+ "negative-int" => -> { Type::Combinator.negative_int },
46
+ "non-positive-int" => -> { Type::Combinator.non_positive_int }
47
+ }.freeze
48
+ private_constant :REGISTRY
49
+
50
+ module_function
51
+
52
+ # @param name [String] kebab-case refinement name.
53
+ # @return [Rigor::Type, nil] the matching refinement
54
+ # carrier, or `nil` if the name is not registered.
55
+ def lookup(name)
56
+ builder = REGISTRY[name.to_s]
57
+ builder&.call
58
+ end
59
+
60
+ def known?(name)
61
+ REGISTRY.key?(name.to_s)
62
+ end
63
+
64
+ def known_names
65
+ REGISTRY.keys
66
+ end
67
+ end
68
+ end
69
+ end
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
  require "prism"
5
5
 
6
+ require_relative "../configuration"
6
7
  require_relative "../environment"
7
8
  require_relative "../scope"
8
9
  require_relative "../source/node_locator"
@@ -47,19 +48,20 @@ module Rigor
47
48
  private
48
49
 
49
50
  def parse_options
50
- options = { format: "text", trace: false }
51
+ options = { format: "text", trace: false, config: Configuration::DEFAULT_PATH }
51
52
 
52
53
  parser = OptionParser.new do |opts|
53
54
  opts.banner = USAGE
54
55
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
55
56
  opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
57
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
56
58
  end
57
59
  parser.parse!(@argv)
58
60
 
59
61
  options
60
62
  end
61
63
 
62
- def execute(target:, options:)
64
+ def execute(target:, options:) # rubocop:disable Metrics/AbcSize
63
65
  file, line, column = target
64
66
  return 1 unless file_exists?(file)
65
67
 
@@ -72,7 +74,8 @@ module Rigor
72
74
  return 1 if node.nil?
73
75
 
74
76
  tracer = options[:trace] ? Inference::FallbackTracer.new : nil
75
- base_scope = Scope.empty(environment: project_environment(file))
77
+ configuration = Configuration.load(options.fetch(:config))
78
+ base_scope = Scope.empty(environment: project_environment(file, configuration))
76
79
 
77
80
  # Build a per-node scope index so locals bound earlier in the
78
81
  # file flow into the scope used to type the queried node. We
@@ -95,8 +98,11 @@ module Rigor
95
98
  # walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
96
99
  # so probes against files outside the current process's CWD still
97
100
  # see the right `sig/` tree.
98
- def project_environment(_file)
99
- Environment.for_project
101
+ def project_environment(_file, configuration)
102
+ Environment.for_project(
103
+ libraries: configuration.libraries,
104
+ signature_paths: configuration.signature_paths
105
+ )
100
106
  end
101
107
 
102
108
  def file_exists?(file)