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.
- checksums.yaml +4 -4
- data/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/lib/rigor/analysis/check_rules.rb +297 -5
- data/lib/rigor/analysis/diagnostic.rb +13 -2
- data/lib/rigor/analysis/runner.rb +52 -5
- data/lib/rigor/builtins/imported_refinements.rb +69 -0
- data/lib/rigor/cli/type_of_command.rb +11 -5
- data/lib/rigor/cli/type_scan_command.rb +13 -8
- data/lib/rigor/cli.rb +26 -6
- data/lib/rigor/configuration.rb +18 -2
- data/lib/rigor/environment.rb +3 -1
- data/lib/rigor/inference/acceptance.rb +180 -0
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
- data/lib/rigor/inference/expression_typer.rb +151 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/narrowing.rb +471 -10
- data/lib/rigor/inference/scope_indexer.rb +66 -0
- data/lib/rigor/inference/statement_evaluator.rb +305 -2
- data/lib/rigor/rbs_extended.rb +174 -14
- data/lib/rigor/scope.rb +44 -5
- data/lib/rigor/type/combinator.rb +69 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +5 -2
- data/sig/rigor/rbs_extended.rbs +25 -1
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor/type.rbs +51 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|