Module: Rigor::Analysis::CheckRules

Defined in:
lib/rigor/analysis/check_rules.rb

Overview

First-preview catalogue of ‘rigor check` diagnostic rules.

The rules are intentionally narrow: they fire ONLY when the engine is confident enough to make a useful claim, and they MUST NOT raise on unrecognised AST shapes, RBS gaps, or missing scope information. Each rule consumes the per-node scope index produced by ‘Rigor::Inference::ScopeIndexer.index` and yields zero or more `Rigor::Analysis::Diagnostic` values.

The first shipped rule, ‘UndefinedMethodOnTypedReceiver`, flags an explicit-receiver `Prism::CallNode` whose receiver statically resolves to a `Type::Nominal` or `Type::Singleton` known to the analyzer’s RBS environment AND whose method name does not appear on that class’s instance / singleton method table. This is the canonical “type check” signal (“Foo has no method bar”), but it explicitly does NOT fire for:

  • implicit-self calls (no ‘node.receiver`) — too noisy without per-method RBS for every helper in the class.

  • dynamic / unknown receivers (‘Dynamic`, `Top`, `Union`) — by definition we cannot enumerate the method set.

  • shape carriers (‘Tuple`, `HashShape`, `Constant`) — their dispatch goes through `ShapeDispatch` / `ConstantFolding` which the rule does not yet model.

  • receivers whose class name is NOT registered in the loader (RBS-blind environments, unknown stdlib).

The above list is the deliberate conservative envelope of the first preview; later slices broaden it. rubocop:disable Metrics/ModuleLength

Constant Summary collapse

RULE_UNDEFINED_METHOD =

Stable identifiers for each rule. Used by the configuration ‘disable:` list and the in-source `# rigor:disable <rule>` suppression comment system to identify diagnostics by category. Rule identifiers are kebab-case strings; new rules MUST register here so user configuration can refer to them.

"undefined-method"
RULE_WRONG_ARITY =
"wrong-arity"
RULE_ARGUMENT_TYPE =
"argument-type-mismatch"
RULE_NIL_RECEIVER =
"possible-nil-receiver"
RULE_DUMP_TYPE =
"dump-type"
RULE_ASSERT_TYPE =
"assert-type"
RULE_ALWAYS_RAISES =
"always-raises"
ALL_RULES =
[
  RULE_UNDEFINED_METHOD,
  RULE_WRONG_ARITY,
  RULE_ARGUMENT_TYPE,
  RULE_NIL_RECEIVER,
  RULE_DUMP_TYPE,
  RULE_ASSERT_TYPE,
  RULE_ALWAYS_RAISES
].freeze

Class Method Summary collapse

Class Method Details

.diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) ⇒ Array<Rigor::Analysis::Diagnostic>

Yields diagnostics for every unrecognised method call on a typed receiver in ‘root`’s subtree. The caller MUST have already produced ‘scope_index` through `Rigor::Inference::ScopeIndexer.index(root, default_scope:)`.

Parameters:

  • path (String)

    used to populate ‘Diagnostic#path`; the rule does not open files.

  • root (Prism::Node)
  • scope_index (Hash{Prism::Node => Rigor::Scope})

Returns:



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rigor/analysis/check_rules.rb', line 80

def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
  diagnostics = []
  Source::NodeWalker.each(root) do |node|
    next unless node.is_a?(Prism::CallNode)

    diagnostic = undefined_method_diagnostic(path, node, scope_index)
    diagnostics << diagnostic if diagnostic

    arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
    diagnostics << arity_diagnostic if arity_diagnostic

    arg_type_diagnostic = argument_type_diagnostic(path, node, scope_index)
    diagnostics << arg_type_diagnostic if arg_type_diagnostic

    nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
    diagnostics << nil_diagnostic if nil_diagnostic

    dump_diagnostic = dump_type_diagnostic(path, node, scope_index)
    diagnostics << dump_diagnostic if dump_diagnostic

    assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
    diagnostics << assert_diagnostic if assert_diagnostic

    raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
    diagnostics << raises_diagnostic if raises_diagnostic
  end
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
end

.filter_suppressed(diagnostics, comments:, disabled_rules:) ⇒ Object

v0.0.2 #6 — diagnostic suppression. Two kinds of suppression compose:

  • Project-level: ‘disabled_rules` is the project’s ‘.rigor.yml` `disable:` list. Any diagnostic whose `rule` is in the list is dropped.

  • In-source: ‘# rigor:disable <rule1>, <rule2>` on the same line as the offending expression suppresses the matching diagnostic for that line only. `# rigor:disable all` on a line suppresses every rule on that line.

Diagnostics with ‘rule == nil` (parse errors, path errors, internal analyzer errors) are NEVER suppressed — they represent failures the user cannot silence away.



125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/rigor/analysis/check_rules.rb', line 125

def filter_suppressed(diagnostics, comments:, disabled_rules:)
  suppressions = parse_suppression_comments(comments)
  disabled = disabled_rules.to_set(&:to_s)

  diagnostics.reject do |diagnostic|
    rule = diagnostic.rule
    next false if rule.nil?
    next true if disabled.include?(rule)

    line_rules = suppressions[diagnostic.line]
    line_rules && (line_rules.include?("all") || line_rules.include?(rule))
  end
end

.parse_suppression_comments(comments) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/rigor/analysis/check_rules.rb', line 142

def parse_suppression_comments(comments)
  result = Hash.new { |h, k| h[k] = Set.new }
  comments.each do |comment|
    source = comment.location.slice
    match = SUPPRESSION_PATTERN.match(source)
    next if match.nil?

    rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
    rules.each { |rule| result[comment.location.start_line] << rule }
  end
  result
end