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 =

Canonical identifiers for each rule. Per ADR-8 §“Diagnostic ID family hierarchy”, rule names are ‘family.rule-name` two-segment strings; the families group diagnostics by where they originate (`call.*` for call-site rules, `flow.*` for flow-analysis proofs, `assert.*` for runtime-assertion rules, `dump.*` for debug helpers, `def.*` for method-definition rules). Used by the configuration `disable:` list and the in-source `# rigor:disable <rule>` suppression comment system; new rules MUST register here so user configuration can refer to them.

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

Backward-compat alias table (ADR-8 § “Backward compatibility”). Existing user code with ‘# rigor:disable undefined-method` / `disable: [undefined-method]` keeps working — the legacy unprefixed identifiers map to their canonical `family.rule-name` form here. Removing the aliases is a future ADR once user code has migrated; until then, both spellings resolve identically.

{
  "undefined-method" => RULE_UNDEFINED_METHOD,
  "wrong-arity" => RULE_WRONG_ARITY,
  "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
  "possible-nil-receiver" => RULE_NIL_RECEIVER,
  "dump-type" => RULE_DUMP_TYPE,
  "assert-type" => RULE_ASSERT_TYPE,
  "always-raises" => RULE_ALWAYS_RAISES
}.freeze
RULE_FAMILIES =

Family wildcard — a ‘<family>` token in a suppression comment or `disable:` list disables every rule whose canonical id starts with `<family>.`. Per ADR-8 § “1”.

%w[call flow assert dump def].freeze

Class Method Summary collapse

Class Method Details

.call_node_diagnostics(path, node, scope_index) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
# File 'lib/rigor/analysis/check_rules.rb', line 137

def call_node_diagnostics(path, node, scope_index)
  [
    undefined_method_diagnostic(path, node, scope_index),
    wrong_arity_diagnostic(path, node, scope_index),
    argument_type_diagnostic(path, node, scope_index),
    nil_receiver_diagnostic(path, node, scope_index),
    dump_type_diagnostic(path, node, scope_index),
    assert_type_diagnostic(path, node, scope_index),
    always_raises_diagnostic(path, node, scope_index)
  ].compact
end

.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:



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

def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
  diagnostics = []
  Source::NodeWalker.each(root) do |node|
    if node.is_a?(Prism::CallNode)
      diagnostics.concat(call_node_diagnostics(path, node, scope_index))
    elsif node.is_a?(Prism::DefNode)
      return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
      diagnostics << return_diagnostic if return_diagnostic
    end
  end
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
end

.expand_rule_tokens(tokens) ⇒ Object

Expands a list of user-supplied rule tokens into the canonical-id set per ADR-8 § “Backward compatibility”. ‘disabled_rules` accepts unprefixed legacy names (`undefined-method`), canonical names (`call.undefined-method`), and family wildcards (`call`).



200
201
202
203
204
# File 'lib/rigor/analysis/check_rules.rb', line 200

def expand_rule_tokens(tokens)
  Array(tokens).each_with_object(Set.new) do |token, set|
    set.merge(expand_token(token.to_s))
  end
end

.expand_token(token) ⇒ Object



206
207
208
209
210
211
# File 'lib/rigor/analysis/check_rules.rb', line 206

def expand_token(token)
  return ["all"] if token == "all"

  resolved = resolve_rule_token(token)
  resolved.nil? || resolved.empty? ? [token] : resolved
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.



165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/rigor/analysis/check_rules.rb', line 165

def filter_suppressed(diagnostics, comments:, disabled_rules:)
  suppressions = parse_suppression_comments(comments)
  disabled = expand_rule_tokens(disabled_rules)

  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



182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/rigor/analysis/check_rules.rb', line 182

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 { |token| result[comment.location.start_line].merge(expand_token(token)) }
  end
  result
end

.resolve_rule_token(token) ⇒ Object

Resolves a user-supplied rule token (‘undefined-method`, `call.undefined-method`, or the family wildcard `call`) to the set of canonical rule identifiers it disables. Returns `nil` for `“all”` (the existing wildcard meaning “every rule”), or for unknown tokens.



104
105
106
107
108
109
110
# File 'lib/rigor/analysis/check_rules.rb', line 104

def self.resolve_rule_token(token)
  return nil if token == "all"
  return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
  return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)

  ALL_RULES.include?(token) ? [token] : []
end