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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1264f9aa0f9dfc20f74b28ee7302166c43e0b0c6ba7b30b0cf85b39b42020eb7
4
- data.tar.gz: a324faeb0b6d0b2d87bfd2517c1712cd1511afcd2579d7659f4864a1a7451032
3
+ metadata.gz: 829d5d69b175d04f0f5a0e60d14d308b0df4ad4e11c96a8dd60fc34f3894a6a7
4
+ data.tar.gz: cd913caa8732865e82b463c5e1465b6a724f63e1eeb4023babbcd2b819911429
5
5
  SHA512:
6
- metadata.gz: ca91af212669aa5f62e32c4475b907dd36b1d4dc425ff2ad48f39c1962919bc3e6a7a246eac4b7138bd318c00f9fa9b4f8054612b3b64dee86d8b83b58bbaf36
7
- data.tar.gz: 462e42c61867f6b933d70590aeb4ee52476d8ae1f4e034727d0e4df5928cc4706a4634535813f757de1f5da01bf19ff64e09204d63a31c72c9382fe2be5a831d
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
- 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,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(path: path, root: parse_result.value, scope_index: index)
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
- 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)
@@ -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
- scope = Scope.empty(environment: project_environment)
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
- # so calls scoped to the current project resolve through the
98
- # local RBS tree. Phase 2a does not yet wire stdlib opt-in here;
99
- # that lands when the configuration layer (`.rigor.yml`) gains an
100
- # `rbs:` section.
101
- def project_environment
102
- Environment.for_project
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.each { |diagnostic| @out.puts(diagnostic) }
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 #{file_count} file(s)")
198
+ @out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
179
199
  end
180
200
 
181
201
  def help