Module: Rigor::Analysis::CheckRules

Defined in:
lib/rigor/analysis/check_rules.rb,
lib/rigor/analysis/check_rules/ivar_write_collector.rb,
lib/rigor/analysis/check_rules/dead_assignment_collector.rb,
lib/rigor/analysis/check_rules/always_truthy_condition_collector.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

Defined Under Namespace

Classes: AlwaysTruthyConditionCollector, DeadAssignmentCollector, IvarWriteCollector

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_UNREACHABLE_BRANCH =
"flow.unreachable-branch"
RULE_RETURN_TYPE =
"def.return-type-mismatch"
RULE_VISIBILITY_MISMATCH =
"def.method-visibility-mismatch"
RULE_IVAR_WRITE_MISMATCH =
"def.ivar-write-mismatch"
RULE_DEAD_ASSIGNMENT =
"flow.dead-assignment"
RULE_ALWAYS_TRUTHY_CONDITION =
"flow.always-truthy-condition"
ALL_RULES =
[
  RULE_UNDEFINED_METHOD,
  RULE_WRONG_ARITY,
  RULE_ARGUMENT_TYPE,
  RULE_NIL_RECEIVER,
  RULE_DUMP_TYPE,
  RULE_ASSERT_TYPE,
  RULE_ALWAYS_RAISES,
  RULE_UNREACHABLE_BRANCH,
  RULE_DEAD_ASSIGNMENT,
  RULE_ALWAYS_TRUTHY_CONDITION,
  RULE_RETURN_TYPE,
  RULE_VISIBILITY_MISMATCH,
  RULE_IVAR_WRITE_MISMATCH
].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,
  "unreachable-branch" => RULE_UNREACHABLE_BRANCH,
  "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
  "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
  "dead-assignment" => RULE_DEAD_ASSIGNMENT,
  "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION
}.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

.absorb_suppression_tokens(raw, target) ⇒ Object



304
305
306
307
308
# File 'lib/rigor/analysis/check_rules.rb', line 304

def absorb_suppression_tokens(raw, target)
  raw.to_s.split(/[\s,]+/).reject(&:empty?).each do |token|
    target.merge(expand_token(token))
  end
end

.always_truthy_condition_diagnostics(path, root, scope_index) ⇒ Object

v0.1.2 — ‘flow.always-truthy-condition`. Fires on `if` / `unless` / ternary predicates whose inferred type is a `Type::Constant` AND that don’t fall in the literal-only / inside-loop-or-block / defensive- predicate skip envelope (see ‘Analysis::CheckRules::AlwaysTruthyConditionCollector` for the full triage rationale).



222
223
224
225
226
# File 'lib/rigor/analysis/check_rules.rb', line 222

def always_truthy_condition_diagnostics(path, root, scope_index)
  AlwaysTruthyConditionCollector.new(scope_index).collect(root).map do |result|
    build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
  end
end

.call_node_diagnostics(path, node, scope_index) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/rigor/analysis/check_rules.rb', line 162

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),
    visibility_mismatch_diagnostic(path, node, scope_index)
  ].compact
end

.dead_assignment_diagnostics(path, root, scope_index) ⇒ Object

v0.1.2 — ‘flow.dead-assignment`. Walks every `DefNode` body and emits a diagnostic for each plain `LocalVariableWriteNode` whose target name is never read in the same body. The `Analysis::CheckRules::DeadAssignmentCollector` describes the conservative envelope.



209
210
211
212
213
# File 'lib/rigor/analysis/check_rules.rb', line 209

def dead_assignment_diagnostics(path, root, scope_index)
  DeadAssignmentCollector.new(scope_index).collect(root).map do |result|
    build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
  end
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:



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/rigor/analysis/check_rules.rb', line 142

def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
  diagnostics = []
  Source::NodeWalker.each(root) do |node|
    case node
    when Prism::CallNode
      diagnostics.concat(call_node_diagnostics(path, node, scope_index))
    when Prism::DefNode
      return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
      diagnostics << return_diagnostic if return_diagnostic
    when Prism::IfNode, Prism::UnlessNode
      unreachable = unreachable_branch_diagnostic(path, node, scope_index)
      diagnostics << unreachable if unreachable
    end
  end
  diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
  diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
  diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
  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`).



315
316
317
318
319
# File 'lib/rigor/analysis/check_rules.rb', line 315

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



321
322
323
324
325
326
# File 'lib/rigor/analysis/check_rules.rb', line 321

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

  • File-level (v0.1.2): ‘# rigor:disable-file <rule1>, <rule2>` anywhere in the file suppresses the matching diagnostic for every line. `# rigor:disable-file all` suppresses every rule across the file. Convention is to put the comment near the top, but Rigor accepts it anywhere — the comment scope is “this file” regardless of position.

  • **In-source line**: ‘# 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.



265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/rigor/analysis/check_rules.rb', line 265

def filter_suppressed(diagnostics, comments:, disabled_rules:) # rubocop:disable Metrics/CyclomaticComplexity
  line_suppressions, file_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)
    next true if file_suppressions.include?("all") || file_suppressions.include?(rule)

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

.ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes) ⇒ Object



228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/rigor/analysis/check_rules.rb', line 228

def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
  return [] if writes.size < 2

  first_class = ivar_class_for(writes.first[:type])
  return [] if first_class.nil?

  writes[1..].filter_map do |write|
    other_class = ivar_class_for(write[:type])
    next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class

    build_ivar_write_mismatch_diagnostic(path, write[:node], class_name, ivar_name, first_class, other_class)
  end
end

.ivar_write_mismatch_diagnostics(path, root, scope_index) ⇒ Object

v0.1.2 — ‘def.ivar-write-mismatch`. Walks every ClassNode / ModuleNode body, gathers per-class ivar writes with their rvalue types, and emits a diagnostic when a later write’s concrete class disagrees with the first write’s. The first write per (class, ivar) is treated as the “declared” type; subsequent writes that land on a different concrete class trigger.

Conservative envelope:

  • Only fires when both the first and the offending write resolve to a ‘concrete_class_name` (Nominal / Singleton / Constant / Tuple → “Array” / HashShape →“Hash”). Unions / Dynamic / IntegerRange / shape- varied carriers fall through.

  • ‘NilClass` is an intentional widening idiom (`@x = “value”` then later `@x = nil` to “clear”) — skipped.

  • Singleton-method (‘def self.foo`) bodies are skipped. Class-level ivars (`@x = 1` outside any def, in the class body) are also skipped — they’re a separate surface (‘Module#@var`) the engine doesn’t yet model.



195
196
197
198
199
200
201
# File 'lib/rigor/analysis/check_rules.rb', line 195

def ivar_write_mismatch_diagnostics(path, root, scope_index)
  IvarWriteCollector.new(scope_index).collect(root).flat_map do |class_name, writes_by_ivar|
    writes_by_ivar.flat_map do |ivar_name, writes|
      ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
    end
  end
end

.parse_suppression_comments(comments) ⇒ Array<(Hash{Integer => Set}, Set)>

Returns pair of ‘(line_suppressions, file_suppressions)`. Line suppressions are keyed by source line number; file suppressions apply to every line.

Returns:

  • (Array<(Hash{Integer => Set}, Set)>)

    pair of ‘(line_suppressions, file_suppressions)`. Line suppressions are keyed by source line number; file suppressions apply to every line.



290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/rigor/analysis/check_rules.rb', line 290

def parse_suppression_comments(comments)
  line_suppressions = Hash.new { |h, k| h[k] = Set.new }
  file_suppressions = Set.new
  comments.each do |comment|
    source = comment.location.slice
    if (match = FILE_SUPPRESSION_PATTERN.match(source))
      absorb_suppression_tokens(match[:rules], file_suppressions)
    elsif (match = LINE_SUPPRESSION_PATTERN.match(source))
      absorb_suppression_tokens(match[:rules], line_suppressions[comment.location.start_line])
    end
  end
  [line_suppressions, file_suppressions]
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.



122
123
124
125
126
127
128
# File 'lib/rigor/analysis/check_rules.rb', line 122

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