rigortype 0.0.1 → 0.0.2
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/lib/rigor/analysis/check_rules.rb +212 -5
- data/lib/rigor/analysis/diagnostic.rb +13 -2
- data/lib/rigor/analysis/runner.rb +48 -5
- 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 +13 -2
- data/lib/rigor/environment.rb +3 -1
- data/lib/rigor/inference/acceptance.rb +31 -0
- data/lib/rigor/inference/expression_typer.rb +104 -0
- data/lib/rigor/inference/narrowing.rb +97 -6
- data/lib/rigor/inference/scope_indexer.rb +58 -0
- data/lib/rigor/inference/statement_evaluator.rb +94 -0
- data/lib/rigor/rbs_extended.rb +109 -13
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +5 -2
- data/sig/rigor/rbs_extended.rbs +22 -1
- data/sig/rigor/scope.rbs +3 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 829d5d69b175d04f0f5a0e60d14d308b0df4ad4e11c96a8dd60fc34f3894a6a7
|
|
4
|
+
data.tar.gz: cd913caa8732865e82b463c5e1465b6a724f63e1eeb4023babbcd2b819911429
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8892ce6470ba6af8f9b1c30a2abb3911b622cb4ac5d68261cb20c0ba0859b2361536bec1add1ed42d4d61fd070394f0f5f51c2ac585c0c12323659e58caf3f45
|
|
7
|
+
data.tar.gz: 93eb3833381a707964a637277c5464f0ee1fb9cef75b39c995abff1eb42accfdc03ee8465b468f01497a9e1521ecfd6bb4faba7a21b8fb86492fba3a171536d2
|
|
@@ -41,6 +41,28 @@ 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
|
+
|
|
57
|
+
ALL_RULES = [
|
|
58
|
+
RULE_UNDEFINED_METHOD,
|
|
59
|
+
RULE_WRONG_ARITY,
|
|
60
|
+
RULE_ARGUMENT_TYPE,
|
|
61
|
+
RULE_NIL_RECEIVER,
|
|
62
|
+
RULE_DUMP_TYPE,
|
|
63
|
+
RULE_ASSERT_TYPE
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
44
66
|
module_function
|
|
45
67
|
|
|
46
68
|
# Yields diagnostics for every unrecognised method call on
|
|
@@ -53,7 +75,7 @@ module Rigor
|
|
|
53
75
|
# @param root [Prism::Node]
|
|
54
76
|
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
55
77
|
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
56
|
-
def diagnose(path:, root:, scope_index:) # rubocop:disable Metrics/CyclomaticComplexity
|
|
78
|
+
def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
57
79
|
diagnostics = []
|
|
58
80
|
Source::NodeWalker.each(root) do |node|
|
|
59
81
|
next unless node.is_a?(Prism::CallNode)
|
|
@@ -64,6 +86,9 @@ module Rigor
|
|
|
64
86
|
arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
|
|
65
87
|
diagnostics << arity_diagnostic if arity_diagnostic
|
|
66
88
|
|
|
89
|
+
arg_type_diagnostic = argument_type_diagnostic(path, node, scope_index)
|
|
90
|
+
diagnostics << arg_type_diagnostic if arg_type_diagnostic
|
|
91
|
+
|
|
67
92
|
nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
|
|
68
93
|
diagnostics << nil_diagnostic if nil_diagnostic
|
|
69
94
|
|
|
@@ -73,7 +98,53 @@ module Rigor
|
|
|
73
98
|
assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
|
|
74
99
|
diagnostics << assert_diagnostic if assert_diagnostic
|
|
75
100
|
end
|
|
76
|
-
diagnostics
|
|
101
|
+
filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# v0.0.2 #6 — diagnostic suppression. Two kinds of
|
|
105
|
+
# suppression compose:
|
|
106
|
+
#
|
|
107
|
+
# - **Project-level**: `disabled_rules` is the
|
|
108
|
+
# project's `.rigor.yml` `disable:` list. Any
|
|
109
|
+
# diagnostic whose `rule` is in the list is dropped.
|
|
110
|
+
# - **In-source**: `# rigor:disable <rule1>, <rule2>`
|
|
111
|
+
# on the same line as the offending expression
|
|
112
|
+
# suppresses the matching diagnostic for that line
|
|
113
|
+
# only. `# rigor:disable all` on a line suppresses
|
|
114
|
+
# every rule on that line.
|
|
115
|
+
#
|
|
116
|
+
# Diagnostics with `rule == nil` (parse errors, path
|
|
117
|
+
# errors, internal analyzer errors) are NEVER
|
|
118
|
+
# suppressed — they represent failures the user cannot
|
|
119
|
+
# silence away.
|
|
120
|
+
def filter_suppressed(diagnostics, comments:, disabled_rules:)
|
|
121
|
+
suppressions = parse_suppression_comments(comments)
|
|
122
|
+
disabled = disabled_rules.to_set(&:to_s)
|
|
123
|
+
|
|
124
|
+
diagnostics.reject do |diagnostic|
|
|
125
|
+
rule = diagnostic.rule
|
|
126
|
+
next false if rule.nil?
|
|
127
|
+
next true if disabled.include?(rule)
|
|
128
|
+
|
|
129
|
+
line_rules = suppressions[diagnostic.line]
|
|
130
|
+
line_rules && (line_rules.include?("all") || line_rules.include?(rule))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w,\s-]+)/
|
|
135
|
+
private_constant :SUPPRESSION_PATTERN
|
|
136
|
+
|
|
137
|
+
def parse_suppression_comments(comments)
|
|
138
|
+
result = Hash.new { |h, k| h[k] = Set.new }
|
|
139
|
+
comments.each do |comment|
|
|
140
|
+
source = comment.location.slice
|
|
141
|
+
match = SUPPRESSION_PATTERN.match(source)
|
|
142
|
+
next if match.nil?
|
|
143
|
+
|
|
144
|
+
rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
|
|
145
|
+
rules.each { |rule| result[comment.location.start_line] << rule }
|
|
146
|
+
end
|
|
147
|
+
result
|
|
77
148
|
end
|
|
78
149
|
|
|
79
150
|
# rubocop:disable Metrics/ClassLength
|
|
@@ -361,13 +432,14 @@ module Rigor
|
|
|
361
432
|
# The diagnostic does NOT count toward `Result#error_count`
|
|
362
433
|
# so a fixture peppered with `dump_type` calls still
|
|
363
434
|
# passes `rigor check`.
|
|
364
|
-
def dump_type_diagnostic(path, call_node, scope_index)
|
|
435
|
+
def dump_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
|
|
365
436
|
return nil unless rigor_testing_call?(call_node, :dump_type)
|
|
366
437
|
return nil if call_node.arguments.nil? || call_node.arguments.arguments.empty?
|
|
367
438
|
|
|
368
439
|
arg = call_node.arguments.arguments.first
|
|
369
440
|
scope = scope_index[arg] || scope_index[call_node]
|
|
370
441
|
return nil if scope.nil?
|
|
442
|
+
return nil if inside_rigor_testing?(scope)
|
|
371
443
|
|
|
372
444
|
type = scope.type_of(arg)
|
|
373
445
|
location = call_node.message_loc || call_node.location
|
|
@@ -376,7 +448,8 @@ module Rigor
|
|
|
376
448
|
line: location.start_line,
|
|
377
449
|
column: location.start_column + 1,
|
|
378
450
|
message: "dump_type: #{type.describe(:short)}",
|
|
379
|
-
severity: :info
|
|
451
|
+
severity: :info,
|
|
452
|
+
rule: RULE_DUMP_TYPE
|
|
380
453
|
)
|
|
381
454
|
end
|
|
382
455
|
|
|
@@ -388,7 +461,7 @@ module Rigor
|
|
|
388
461
|
# is emitted; matching calls produce no output. This
|
|
389
462
|
# lets a fixture document its expected types inline:
|
|
390
463
|
# subsequent `rigor check` runs flag any drift.
|
|
391
|
-
def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
|
|
464
|
+
def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
392
465
|
return nil unless rigor_testing_call?(call_node, :assert_type)
|
|
393
466
|
return nil if call_node.arguments.nil? || call_node.arguments.arguments.size < 2
|
|
394
467
|
|
|
@@ -398,6 +471,7 @@ module Rigor
|
|
|
398
471
|
value_node = call_node.arguments.arguments[1]
|
|
399
472
|
scope = scope_index[value_node] || scope_index[call_node]
|
|
400
473
|
return nil if scope.nil?
|
|
474
|
+
return nil if inside_rigor_testing?(scope)
|
|
401
475
|
|
|
402
476
|
actual = scope.type_of(value_node).describe(:short)
|
|
403
477
|
expected = expected_node.unescaped.to_s
|
|
@@ -419,6 +493,24 @@ module Rigor
|
|
|
419
493
|
RIGOR_TESTING_RECEIVERS = ["Rigor", "Rigor::Testing", "Testing"].freeze
|
|
420
494
|
private_constant :RIGOR_TESTING_RECEIVERS
|
|
421
495
|
|
|
496
|
+
# The dump/assert helpers' own implementation methods
|
|
497
|
+
# call back into `Testing.dump_type` / `assert_type` to
|
|
498
|
+
# share the no-op runtime stub. We do NOT want those
|
|
499
|
+
# internal calls to surface diagnostics — they are
|
|
500
|
+
# reflexive plumbing, not user assertions. This filter
|
|
501
|
+
# skips diagnostics when the call site's `self_type` is
|
|
502
|
+
# the `Rigor` or `Rigor::Testing` module itself.
|
|
503
|
+
SELF_REFERENTIAL_SCOPES = ["Rigor", "Rigor::Testing"].freeze
|
|
504
|
+
private_constant :SELF_REFERENTIAL_SCOPES
|
|
505
|
+
|
|
506
|
+
def inside_rigor_testing?(scope)
|
|
507
|
+
self_type = scope.self_type
|
|
508
|
+
return false if self_type.nil?
|
|
509
|
+
return false unless self_type.respond_to?(:class_name)
|
|
510
|
+
|
|
511
|
+
SELF_REFERENTIAL_SCOPES.include?(self_type.class_name)
|
|
512
|
+
end
|
|
513
|
+
|
|
422
514
|
def rigor_testing_call?(call_node, method_name)
|
|
423
515
|
return false unless call_node.name == method_name
|
|
424
516
|
|
|
@@ -449,6 +541,7 @@ module Rigor
|
|
|
449
541
|
def build_assert_type_diagnostic(path, call_node, expected, actual)
|
|
450
542
|
location = call_node.message_loc || call_node.location
|
|
451
543
|
Diagnostic.new(
|
|
544
|
+
rule: RULE_ASSERT_TYPE,
|
|
452
545
|
path: path,
|
|
453
546
|
line: location.start_line,
|
|
454
547
|
column: location.start_column + 1,
|
|
@@ -460,6 +553,7 @@ module Rigor
|
|
|
460
553
|
def build_nil_receiver_diagnostic(path, call_node)
|
|
461
554
|
location = call_node.message_loc || call_node.location
|
|
462
555
|
Diagnostic.new(
|
|
556
|
+
rule: RULE_NIL_RECEIVER,
|
|
463
557
|
path: path,
|
|
464
558
|
line: location.start_line,
|
|
465
559
|
column: location.start_column + 1,
|
|
@@ -468,6 +562,117 @@ module Rigor
|
|
|
468
562
|
)
|
|
469
563
|
end
|
|
470
564
|
|
|
565
|
+
# v0.0.2 #4 — argument-type-mismatch diagnostic.
|
|
566
|
+
# Walks a call's positional arguments and checks each
|
|
567
|
+
# against the matching parameter's RBS type via
|
|
568
|
+
# `Rigor::Inference::Acceptance`. Emits an `:error`
|
|
569
|
+
# for the first argument whose type the parameter
|
|
570
|
+
# does NOT accept under the gradual mode.
|
|
571
|
+
#
|
|
572
|
+
# Conservative envelope (matches the wrong-arity rule
|
|
573
|
+
# plus a few additional skips):
|
|
574
|
+
# - Receiver must be Nominal / Singleton / Constant
|
|
575
|
+
# (the same `concrete_class_name` test).
|
|
576
|
+
# - Method must be in RBS.
|
|
577
|
+
# - Method must have exactly ONE method type
|
|
578
|
+
# (overload). Multi-overload checking is left for
|
|
579
|
+
# a follow-up because picking the "intended"
|
|
580
|
+
# overload requires the dispatcher's full
|
|
581
|
+
# acceptance plumbing.
|
|
582
|
+
# - The selected overload must have NO
|
|
583
|
+
# rest_positionals, NO required keywords, NO
|
|
584
|
+
# trailing positionals.
|
|
585
|
+
# - The call must use plain positional arguments
|
|
586
|
+
# (no splat / kw / block-pass / forwarded).
|
|
587
|
+
# - Per-argument: skip when EITHER side is `Dynamic`
|
|
588
|
+
# (the call cannot be statically refuted).
|
|
589
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
590
|
+
def argument_type_diagnostic(path, call_node, scope_index)
|
|
591
|
+
return nil if call_node.receiver.nil?
|
|
592
|
+
return nil unless plain_positional_call?(call_node)
|
|
593
|
+
|
|
594
|
+
scope = scope_index[call_node]
|
|
595
|
+
return nil if scope.nil?
|
|
596
|
+
|
|
597
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
598
|
+
class_name = concrete_class_name(receiver_type)
|
|
599
|
+
return nil if class_name.nil?
|
|
600
|
+
|
|
601
|
+
# NOTE: unlike the undefined-method / wrong-arity
|
|
602
|
+
# rules, we deliberately do NOT skip when
|
|
603
|
+
# `discovered_method?` matches. When the user
|
|
604
|
+
# supplies BOTH a `def` and an RBS sig, the sig is
|
|
605
|
+
# the authoritative parameter contract and we
|
|
606
|
+
# should validate calls against it.
|
|
607
|
+
loader = scope.environment.rbs_loader
|
|
608
|
+
return nil if loader.nil?
|
|
609
|
+
return nil unless loader.class_known?(class_name)
|
|
610
|
+
return nil unless definition_available?(loader, receiver_type, class_name)
|
|
611
|
+
|
|
612
|
+
method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
|
|
613
|
+
return nil if method_def.nil? || method_def == true
|
|
614
|
+
return nil unless method_def.method_types.size == 1
|
|
615
|
+
|
|
616
|
+
mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope)
|
|
617
|
+
return nil if mismatch.nil?
|
|
618
|
+
|
|
619
|
+
build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
620
|
+
end
|
|
621
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
622
|
+
|
|
623
|
+
def first_argument_mismatch(method_type, call_node, scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
624
|
+
function = method_type.type
|
|
625
|
+
return nil unless argument_check_eligible?(function)
|
|
626
|
+
|
|
627
|
+
params = function.required_positionals + function.optional_positionals
|
|
628
|
+
arguments = call_node.arguments&.arguments || []
|
|
629
|
+
arguments.each_with_index do |arg, index|
|
|
630
|
+
param = params[index]
|
|
631
|
+
next if param.nil? # arity mismatch is the wrong-arity rule's concern.
|
|
632
|
+
|
|
633
|
+
param_type = translate_param_type(param.type, scope.environment)
|
|
634
|
+
next if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
|
|
635
|
+
|
|
636
|
+
arg_type = scope.type_of(arg)
|
|
637
|
+
next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
|
|
638
|
+
|
|
639
|
+
result = Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual)
|
|
640
|
+
return { node: arg, name: param.name, expected: param_type, actual: arg_type } if result.no?
|
|
641
|
+
end
|
|
642
|
+
nil
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def argument_check_eligible?(function)
|
|
646
|
+
function.rest_positionals.nil? &&
|
|
647
|
+
function.required_keywords.empty? &&
|
|
648
|
+
function.optional_keywords.empty? &&
|
|
649
|
+
function.rest_keywords.nil? &&
|
|
650
|
+
function.trailing_positionals.empty?
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def translate_param_type(rbs_type, _environment)
|
|
654
|
+
Inference::RbsTypeTranslator.translate(rbs_type)
|
|
655
|
+
rescue StandardError
|
|
656
|
+
Type::Combinator.untyped
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
660
|
+
location = mismatch[:node].location
|
|
661
|
+
method_label = "`#{call_node.name}' on #{class_name}"
|
|
662
|
+
parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
|
|
663
|
+
message = "argument type mismatch at #{parameter_label}: " \
|
|
664
|
+
"expected #{mismatch[:expected].describe(:short)}, " \
|
|
665
|
+
"got #{mismatch[:actual].describe(:short)}"
|
|
666
|
+
Diagnostic.new(
|
|
667
|
+
rule: RULE_ARGUMENT_TYPE,
|
|
668
|
+
path: path,
|
|
669
|
+
line: location.start_line,
|
|
670
|
+
column: location.start_column + 1,
|
|
671
|
+
message: message,
|
|
672
|
+
severity: :error
|
|
673
|
+
)
|
|
674
|
+
end
|
|
675
|
+
|
|
471
676
|
# rubocop:disable Metrics/ParameterLists
|
|
472
677
|
def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
473
678
|
location = call_node.message_loc || call_node.location
|
|
@@ -475,6 +680,7 @@ module Rigor
|
|
|
475
680
|
method_label = "`#{call_node.name}' on #{class_name}"
|
|
476
681
|
message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
|
|
477
682
|
Diagnostic.new(
|
|
683
|
+
rule: RULE_WRONG_ARITY,
|
|
478
684
|
path: path,
|
|
479
685
|
line: location.start_line,
|
|
480
686
|
column: location.start_column + 1,
|
|
@@ -488,6 +694,7 @@ module Rigor
|
|
|
488
694
|
location = call_node.message_loc || call_node.location
|
|
489
695
|
rendered_receiver = receiver_type.describe
|
|
490
696
|
Diagnostic.new(
|
|
697
|
+
rule: RULE_UNDEFINED_METHOD,
|
|
491
698
|
path: path,
|
|
492
699
|
line: location.start_line,
|
|
493
700
|
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,6 +4,7 @@ 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"
|
|
8
9
|
require_relative "check_rules"
|
|
9
10
|
require_relative "diagnostic"
|
|
@@ -11,11 +12,12 @@ require_relative "result"
|
|
|
11
12
|
|
|
12
13
|
module Rigor
|
|
13
14
|
module Analysis
|
|
14
|
-
class Runner
|
|
15
|
+
class Runner # rubocop:disable Metrics/ClassLength
|
|
15
16
|
RUBY_GLOB = "**/*.rb"
|
|
16
17
|
|
|
17
|
-
def initialize(configuration:)
|
|
18
|
+
def initialize(configuration:, explain: false)
|
|
18
19
|
@configuration = configuration
|
|
20
|
+
@explain = explain
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -27,7 +29,10 @@ module Rigor
|
|
|
27
29
|
# is built once at run start through `Environment.for_project`
|
|
28
30
|
# so all files share the same RBS load.
|
|
29
31
|
def run(paths = @configuration.paths)
|
|
30
|
-
environment = Environment.for_project
|
|
32
|
+
environment = Environment.for_project(
|
|
33
|
+
libraries: @configuration.libraries,
|
|
34
|
+
signature_paths: @configuration.signature_paths
|
|
35
|
+
)
|
|
31
36
|
expansion = expand_paths(paths)
|
|
32
37
|
|
|
33
38
|
diagnostics = expansion.fetch(:errors)
|
|
@@ -73,13 +78,20 @@ module Rigor
|
|
|
73
78
|
)
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
def analyze_file(path, environment)
|
|
81
|
+
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
77
82
|
parse_result = Prism.parse_file(path)
|
|
78
83
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
79
84
|
|
|
80
85
|
scope = Scope.empty(environment: environment)
|
|
81
86
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
82
|
-
CheckRules.diagnose(
|
|
87
|
+
diagnostics = CheckRules.diagnose(
|
|
88
|
+
path: path,
|
|
89
|
+
root: parse_result.value,
|
|
90
|
+
scope_index: index,
|
|
91
|
+
comments: parse_result.comments,
|
|
92
|
+
disabled_rules: @configuration.disabled_rules
|
|
93
|
+
)
|
|
94
|
+
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
83
95
|
rescue Errno::ENOENT => e
|
|
84
96
|
[
|
|
85
97
|
Diagnostic.new(
|
|
@@ -102,6 +114,37 @@ module Rigor
|
|
|
102
114
|
]
|
|
103
115
|
end
|
|
104
116
|
|
|
117
|
+
# v0.0.2 #10 — fail-soft fallback explanation. When
|
|
118
|
+
# `--explain` is set the runner additionally walks the
|
|
119
|
+
# file with `Rigor::Inference::CoverageScanner` and emits
|
|
120
|
+
# one `:info` diagnostic per directly-unrecognized node,
|
|
121
|
+
# naming the node class and the type the engine fell back
|
|
122
|
+
# to. The CoverageScanner is the canonical "first-event-
|
|
123
|
+
# per-node" probe: it already filters out pass-through
|
|
124
|
+
# wrappers (`ProgramNode`, `StatementsNode`,
|
|
125
|
+
# `ParenthesesNode`) so the explain stream is attributable
|
|
126
|
+
# to the leaf node that actually triggered the fallback.
|
|
127
|
+
def explain_diagnostics(path, root, scope)
|
|
128
|
+
return [] unless @explain
|
|
129
|
+
|
|
130
|
+
result = Inference::CoverageScanner.new(scope: scope).scan(root)
|
|
131
|
+
result.events.map { |event| explain_diagnostic(path, event) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def explain_diagnostic(path, event)
|
|
135
|
+
location = event.location
|
|
136
|
+
line = location ? location.start_line : 1
|
|
137
|
+
column = location ? location.start_column + 1 : 1
|
|
138
|
+
Diagnostic.new(
|
|
139
|
+
path: path,
|
|
140
|
+
line: line,
|
|
141
|
+
column: column,
|
|
142
|
+
message: "fail-soft fallback at #{event.node_class}: #{event.inner_type.describe(:short)}",
|
|
143
|
+
severity: :info,
|
|
144
|
+
rule: "fallback"
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
105
148
|
def parse_diagnostics(path, parse_result)
|
|
106
149
|
parse_result.errors.map do |error|
|
|
107
150
|
location = error.location
|
|
@@ -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)
|
|
@@ -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 "../inference/coverage_scanner"
|
|
8
9
|
require_relative "../scope"
|
|
@@ -44,11 +45,13 @@ module Rigor
|
|
|
44
45
|
private
|
|
45
46
|
|
|
46
47
|
def parse_options
|
|
47
|
-
options = { format: "text", limit: 10, show_recognized: false, threshold: nil
|
|
48
|
+
options = { format: "text", limit: 10, show_recognized: false, threshold: nil,
|
|
49
|
+
config: Configuration::DEFAULT_PATH }
|
|
48
50
|
|
|
49
51
|
parser = OptionParser.new do |opts|
|
|
50
52
|
opts.banner = USAGE
|
|
51
53
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
54
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
52
55
|
opts.on("--limit=N", Integer, "Max example events to print (text only)") do |value|
|
|
53
56
|
options[:limit] = value
|
|
54
57
|
end
|
|
@@ -86,7 +89,8 @@ module Rigor
|
|
|
86
89
|
end
|
|
87
90
|
|
|
88
91
|
def scan_paths(paths, options)
|
|
89
|
-
|
|
92
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
93
|
+
scope = Scope.empty(environment: project_environment(configuration))
|
|
90
94
|
scanner = Inference::CoverageScanner.new(scope: scope)
|
|
91
95
|
accumulator = ScanAccumulator.new
|
|
92
96
|
paths.each { |path| scan_one(path, scanner, accumulator) }
|
|
@@ -94,12 +98,13 @@ module Rigor
|
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
# Builds a project-aware environment that auto-detects `<cwd>/sig`
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
# by default and honours the configuration's `libraries:` /
|
|
102
|
+
# `signature_paths:` keys when present.
|
|
103
|
+
def project_environment(configuration)
|
|
104
|
+
Environment.for_project(
|
|
105
|
+
libraries: configuration.libraries,
|
|
106
|
+
signature_paths: configuration.signature_paths
|
|
107
|
+
)
|
|
103
108
|
end
|
|
104
109
|
|
|
105
110
|
def scan_one(path, scanner, accumulator)
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -68,19 +68,21 @@ module Rigor
|
|
|
68
68
|
|
|
69
69
|
options = {
|
|
70
70
|
config: Configuration::DEFAULT_PATH,
|
|
71
|
-
format: "text"
|
|
71
|
+
format: "text",
|
|
72
|
+
explain: false
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
parser = OptionParser.new do |opts|
|
|
75
76
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
76
77
|
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
77
78
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
79
|
+
opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
|
|
78
80
|
end
|
|
79
81
|
parser.parse!(@argv)
|
|
80
82
|
|
|
81
83
|
configuration = Configuration.load(options.fetch(:config))
|
|
82
84
|
paths = @argv.empty? ? configuration.paths : @argv
|
|
83
|
-
result = Analysis::Runner.new(configuration: configuration).run(paths)
|
|
85
|
+
result = Analysis::Runner.new(configuration: configuration, explain: options.fetch(:explain)).run(paths)
|
|
84
86
|
|
|
85
87
|
write_result(result, options.fetch(:format))
|
|
86
88
|
result.success? ? 0 : 1
|
|
@@ -126,6 +128,23 @@ module Rigor
|
|
|
126
128
|
# `rigor type-scan` when no path is given.
|
|
127
129
|
# - plugins: reserved for future plugin contributions
|
|
128
130
|
# (no plugins are loaded today).
|
|
131
|
+
# - disable: list of `rigor check` rule identifiers to
|
|
132
|
+
# silence project-wide. The shipped rules are
|
|
133
|
+
# undefined-method, wrong-arity,
|
|
134
|
+
# argument-type-mismatch, possible-nil-receiver,
|
|
135
|
+
# dump-type, assert-type. In-source
|
|
136
|
+
# `# rigor:disable <rule>` comments at the end
|
|
137
|
+
# of an offending line silence per-line; use
|
|
138
|
+
# `# rigor:disable all` to suppress every rule.
|
|
139
|
+
# - libraries: stdlib libraries to load on top of the
|
|
140
|
+
# bundled defaults (e.g. ["csv", "set"]).
|
|
141
|
+
# Each entry must be a name accepted by
|
|
142
|
+
# `RBS::EnvironmentLoader#has_library?`.
|
|
143
|
+
# - signature_paths:
|
|
144
|
+
# explicit list of `sig/`-style directories.
|
|
145
|
+
# Leave unset (or `null`) to auto-detect
|
|
146
|
+
# `<root>/sig`. Use `[]` to disable
|
|
147
|
+
# project-RBS loading entirely.
|
|
129
148
|
# - cache.path: where Rigor will eventually persist
|
|
130
149
|
# analysis results across runs.
|
|
131
150
|
#
|
|
@@ -167,15 +186,16 @@ module Rigor
|
|
|
167
186
|
# the success and failure cases and reports the affected
|
|
168
187
|
# file count for failures.
|
|
169
188
|
def write_text_result(result)
|
|
189
|
+
result.diagnostics.each { |diagnostic| @out.puts(diagnostic) }
|
|
190
|
+
|
|
170
191
|
if result.success?
|
|
171
|
-
@out.puts("No diagnostics")
|
|
192
|
+
@out.puts("No diagnostics") if result.diagnostics.empty?
|
|
172
193
|
return
|
|
173
194
|
end
|
|
174
195
|
|
|
175
|
-
result.diagnostics.
|
|
176
|
-
file_count = result.diagnostics.map(&:path).uniq.size
|
|
196
|
+
error_files = result.diagnostics.select(&:error?).map(&:path).uniq.size
|
|
177
197
|
@out.puts("")
|
|
178
|
-
@out.puts("#{result.error_count} error(s) in #{
|
|
198
|
+
@out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
|
|
179
199
|
end
|
|
180
200
|
|
|
181
201
|
def help
|