Module: Rigor::Analysis::CheckRules

Defined in:
lib/rigor/analysis/check_rules.rb,
lib/rigor/analysis/check_rules/rule_ids.rb,
lib/rigor/analysis/check_rules/rule_walk.rb,
lib/rigor/analysis/check_rules/main_pass_collector.rb,
lib/rigor/analysis/check_rules/inferred_param_guard.rb,
lib/rigor/analysis/check_rules/ivar_write_collector.rb,
lib/rigor/analysis/check_rules/self_closedness_scanner.rb,
lib/rigor/analysis/check_rules/void_value_use_collector.rb,
lib/rigor/analysis/check_rules/dead_assignment_collector.rb,
lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb,
lib/rigor/analysis/check_rules/return_in_ensure_collector.rb,
lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb,
lib/rigor/analysis/check_rules/unreachable_clause_collector.rb,
lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb,
sig/rigor/analysis/fact_store.rbs,
sig/rigor/analysis/check_rules/dead_assignment_collector.rbs,
sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs

Overview

Catalogue of rigor check diagnostic rules.

Rules fire ONLY when the engine is confident enough to make a useful claim and 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 primary rule (call.undefined-method) flags an explicit-receiver Prism::CallNode whose receiver statically resolves to a class known to the RBS environment and whose method name does not appear on that class's method table. It 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[T], Top, Union) — by definition we cannot enumerate the method set.
  • shape carriers: Tuple → "Array", HashShape → "Hash", Constant → the constant's class — concrete_class_name resolves these to their runtime class for dispatch.
  • receivers whose class name is NOT registered in the loader (RBS-blind environments, unknown stdlib). rubocop:disable Metrics/ModuleLength

Defined Under Namespace

Modules: InferredParamGuard, RuleWalk Classes: AlwaysTruthyConditionCollector, DeadAssignmentCollector, DuplicateHashKeyCollector, IvarWriteCollector, MainPassCollector, ReturnInEnsureCollector, SelfClosednessScanner, ShadowedRescueCollector, UnreachableClauseCollector, VoidValueUseCollector

Constant Summary collapse

OVERRIDE_ANCESTOR_WALK_LIMIT =

ADR-35 slice 1 — bound for the def.override-visibility-reduced ancestor walk, and the public > protected > private ordering used to decide whether an override reduces visibility.

100
VISIBILITY_RANK =
{ public: 2, protected: 1, private: 0 }.freeze
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.

ADR-87 WD4 — this pure-data constant table is split out of the (engine-heavy) check_rules.rb so RuleCatalog — which the CLI's JSON evidence_tier / documentation_url enrichment reads — can require it WITHOUT loading the inference engine. The boot-slimming hit path stays engine-free even when it formats JSON.

"call.undefined-method"
RULE_SELF_UNDEFINED_METHOD =
"call.self-undefined-method"
RULE_UNRESOLVED_TOPLEVEL =
"call.unresolved-toplevel"
RULE_WRONG_ARITY =
"call.wrong-arity"
RULE_ARGUMENT_TYPE =
"call.argument-type-mismatch"
RULE_NIL_RECEIVER =
"call.possible-nil-receiver"
RULE_RAISE_NON_EXCEPTION =
"call.raise-non-exception"
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_OVERRIDE_VISIBILITY_REDUCED =
"def.override-visibility-reduced"
RULE_OVERRIDE_RETURN_WIDENED =
"def.override-return-widened"
RULE_OVERRIDE_PARAM_NARROWED =
"def.override-param-narrowed"
RULE_IVAR_WRITE_MISMATCH =
"def.ivar-write-mismatch"
RULE_DEAD_ASSIGNMENT =
"flow.dead-assignment"
RULE_ALWAYS_TRUTHY_CONDITION =
"flow.always-truthy-condition"
RULE_UNREACHABLE_CLAUSE =
"flow.unreachable-clause"
RULE_DUPLICATE_HASH_KEY =
"flow.duplicate-hash-key"
RULE_RETURN_IN_ENSURE =
"flow.return-in-ensure"
RULE_SHADOWED_RESCUE_CLAUSE =
"flow.shadowed-rescue-clause"
RULE_SUPPRESSION_UNKNOWN_RULE =
"suppression.unknown-rule"
RULE_SUPPRESSION_EMPTY =
"suppression.empty"
RULE_SUPPRESSION_UNKNOWN_MARKER =
"suppression.unknown-marker"
RULE_VALUE_USE_VOID =

ADR-100 — the first static.value-use.* id: a value recovered from an author-declared -> void return, used in value context. Authored :warning, resolved :off by every profile and promoted to :warning only by the use-of-void-value bleeding-edge feature.

"static.value-use.void"
ALL_RULES =
[
  RULE_UNDEFINED_METHOD,
  RULE_SELF_UNDEFINED_METHOD,
  RULE_UNRESOLVED_TOPLEVEL,
  RULE_WRONG_ARITY,
  RULE_ARGUMENT_TYPE,
  RULE_NIL_RECEIVER,
  RULE_RAISE_NON_EXCEPTION,
  RULE_DUMP_TYPE,
  RULE_ASSERT_TYPE,
  RULE_ALWAYS_RAISES,
  RULE_UNREACHABLE_BRANCH,
  RULE_DEAD_ASSIGNMENT,
  RULE_ALWAYS_TRUTHY_CONDITION,
  RULE_UNREACHABLE_CLAUSE,
  RULE_DUPLICATE_HASH_KEY,
  RULE_RETURN_IN_ENSURE,
  RULE_SHADOWED_RESCUE_CLAUSE,
  RULE_RETURN_TYPE,
  RULE_VISIBILITY_MISMATCH,
  RULE_OVERRIDE_VISIBILITY_REDUCED,
  RULE_OVERRIDE_RETURN_WIDENED,
  RULE_OVERRIDE_PARAM_NARROWED,
  RULE_IVAR_WRITE_MISMATCH,
  RULE_SUPPRESSION_UNKNOWN_RULE,
  RULE_SUPPRESSION_EMPTY,
  RULE_SUPPRESSION_UNKNOWN_MARKER,
  RULE_VALUE_USE_VOID
].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,
  "self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
  "wrong-arity" => RULE_WRONG_ARITY,
  "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
  "possible-nil-receiver" => RULE_NIL_RECEIVER,
  "raise-non-exception" => RULE_RAISE_NON_EXCEPTION,
  "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,
  "unreachable-clause" => RULE_UNREACHABLE_CLAUSE,
  "duplicate-hash-key" => RULE_DUPLICATE_HASH_KEY,
  "return-in-ensure" => RULE_RETURN_IN_ENSURE,
  "shadowed-rescue-clause" => RULE_SHADOWED_RESCUE_CLAUSE
}.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 suppression static].freeze
NON_CHECK_DIAGNOSTIC_FAMILIES =

Families of diagnostics the engine emits OUTSIDE the CheckRules catalogue (aggregator-level and reporter-level diagnostics such as rbs_extended.unsatisfied-conformance, dynamic.dependency-source.*, rbs.coverage.*, pre-eval.parse-error), plus the plugin. prefix reserved for plugin-produced identifiers. suppression.unknown-rule treats a dotted token whose first segment appears here as KNOWN and stays silent: these ids are legitimate suppression / severity_overrides: vocabulary the light rule-id table cannot enumerate (plugins load dynamically; aggregator ids live in the engine-heavy runner), so under-warning is the FP-safe direction.

%w[rbs_extended dynamic rbs pre-eval plugin].freeze
NON_CHECK_DIAGNOSTIC_IDS =

Bare (dot-less) diagnostic ids the engine emits outside the catalogue (see the rule: literals in Analysis::Runner / Runner::DiagnosticAggregator). A token equal to one of these is treated as known by suppression.unknown-rule even though it carries no family prefix; extend the list when the runner grows a new bare id.

%w[
  configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.absorb_suppression_tokens(raw, target) ⇒ Object



485
486
487
488
489
# File 'lib/rigor/analysis/check_rules.rb', line 485

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, results) ⇒ 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).



341
342
343
344
345
# File 'lib/rigor/analysis/check_rules.rb', line 341

def always_truthy_condition_diagnostics(path, results)
  results.map do |result|
    build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
  end
end

.build_node_collectors(path, scope_index) ⇒ Object

Constructs the fresh, unpopulated built-in collector set keyed by role, including the main pass. Split out so the converged walk (ADR-53 B4) can build the collectors, drive them via a Rigor::Analysis::CheckRules::RuleWalk::CollectorDriver inside the single Plugin::NodeRuleWalk traversal, and hand the populated set back to diagnose as node_collectors:. The main pass needs path because its per-node diagnostics carry it (ADR-53 B3c hosts it on the same walk).



155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/rigor/analysis/check_rules.rb', line 155

def build_node_collectors(path, scope_index)
  {
    main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
    always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
    unreachable_clauses: UnreachableClauseCollector.new(scope_index),
    shadowed_rescues: ShadowedRescueCollector.new(scope_index),
    ivar_writes: IvarWriteCollector.new(scope_index),
    dead_assignments: DeadAssignmentCollector.new(scope_index),
    duplicate_hash_keys: DuplicateHashKeyCollector.new(scope_index),
    return_in_ensure: ReturnInEnsureCollector.new(scope_index)
  }
end

.call_node_diagnostics(path, node, scope_index) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/rigor/analysis/check_rules.rb', line 254

def call_node_diagnostics(path, node, scope_index)
  [
    undefined_method_diagnostic(path, node, scope_index),
    unresolved_toplevel_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),
    raise_non_exception_diagnostic(path, node, scope_index),
    visibility_mismatch_diagnostic(path, node, scope_index)
  ].compact
end

.comparable(results) ⇒ Object

Normalises a collector's result for value comparison. The fact collectors return Data / Hash structures that already compare by value; the main pass returns Diagnostic objects (plain objects with identity ==), so serialise those to hashes first.



211
212
213
214
215
# File 'lib/rigor/analysis/check_rules.rb', line 211

def comparable(results)
  return results.map(&:to_h) if results.is_a?(Array) && results.first.is_a?(Diagnostic)

  results
end

.dead_assignment_diagnostics(path, dead_assignments) ⇒ 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.



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

def dead_assignment_diagnostics(path, dead_assignments)
  dead_assignments.map do |result|
    build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
  end
end

.diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [], node_collectors: nil) ⇒ 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:).

ADR-53 B4 — when node_collectors is supplied, the converged Plugin::NodeRuleWalk traversal has already populated the built-in collectors (including the main pass) in one shared walk with the plugin node-rules, so they are consumed as-is. When it is nil (a direct caller with no plugin walk, e.g. a unit test), the standalone RuleWalk walk runs here instead, so diagnose stays correct without the converged path.

Parameters:

Returns:



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/rigor/analysis/check_rules.rb', line 95

def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [],
             node_collectors: nil)
  collectors = node_collectors || run_node_collectors(path, root, scope_index)
  diagnostics = collectors[:main_pass].results.dup
  diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
  diagnostics.concat(void_value_use_diagnostics(path, root, scope_index))
  COLLECTOR_DIAGNOSTIC_BUILDERS.each do |role, builder|
    diagnostics.concat(send(builder, path, collectors[role].results))
  end
  # Suppression-marker validation (`suppression.*`) runs BEFORE the filter so its own diagnostics are
  # suppressible like any other rule — `# rigor:disable suppression.unknown-rule` on the offending
  # comment's line works, with no regress (the token itself is known, so it never re-fires).
  diagnostics.concat(suppression_marker_diagnostics(path, comments))
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
end

.diagnose_bare_suppression_marker(path, comment, source, diagnostics) ⇒ Object

A comment carrying the marker word but not the token-bearing suppression grammar. A remainder of nothing but whitespace / commas is a genuinely empty marker (# rigor:disable); anything else (documentation prose like "# rigor:disable <rule> comments") is left alone as an ordinary comment, matching the parse path, which never treats it as a suppression either.



531
532
533
534
535
536
537
538
539
540
541
542
# File 'lib/rigor/analysis/check_rules.rb', line 531

def diagnose_bare_suppression_marker(path, comment, source, diagnostics)
  bare = BARE_SUPPRESSION_MARKER.match(source)
  if bare
    return unless bare[:rest].match?(/\A[\s,]*\z/)

    marker = bare[:file] ? "rigor:disable-file" : "rigor:disable"
    diagnostics << empty_suppression_diagnostic(path, comment, marker)
    return
  end

  diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
end

.diagnose_unknown_suppression_marker(path, comment, source, diagnostics) ⇒ Object

# rigor:disable-next-line <rule> / # rigor:enable <rule> — a marker word Rigor's grammar does not recognise but that reads as an attempted suppression (the RuboCop reflex). Fires only when the remainder is empty or looks like a rule list, so prose mentioning the spelling in backticks stays an ordinary comment — the same escape the empty-marker detection observes.



548
549
550
551
552
553
554
555
556
# File 'lib/rigor/analysis/check_rules.rb', line 548

def diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
  unknown = UNKNOWN_SUPPRESSION_MARKER.match(source)
  return if unknown.nil?

  rest = unknown[:rest]
  return unless rest.match?(/\A[\s,]*\z/) || rest.match?(/\A\s+[\w.,\s-]+\z/)

  diagnostics << unknown_suppression_marker_diagnostic(path, comment, unknown[:marker])
end

.duplicate_hash_key_diagnostics(path, duplicate_keys) ⇒ Object

v0.3.0 — flow.duplicate-hash-key. Emits a diagnostic for each LATER occurrence of a repeated LITERAL key within one Hash literal (braced or bare-kwargs) — Ruby keeps the last entry silently at runtime, so the earlier value is dead. The Analysis::CheckRules::DuplicateHashKeyCollector describes the value-pinned-literal-only envelope (symbols / plain strings / integers / floats / true / false / nil; never cross-kind, never interpolation / constants / calls / splats).



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

def duplicate_hash_key_diagnostics(path, duplicate_keys)
  duplicate_keys.map do |result|
    build_duplicate_hash_key_diagnostic(path, result)
  end
end

.empty_suppression_diagnostic(path, comment, marker) ⇒ Object



600
601
602
603
604
605
606
607
608
609
610
611
# File 'lib/rigor/analysis/check_rules.rb', line 600

def empty_suppression_diagnostic(path, comment, marker)
  Diagnostic.new(
    path: path,
    line: comment.location.start_line,
    column: comment.location.start_column + 1,
    message: "`# #{marker}` lists no rules, so this suppression has no effect. Name the rules to " \
             "suppress (`# #{marker} call.undefined-method`) or use `# #{marker} all`.",
    severity: :warning,
    rule: RULE_SUPPRESSION_EMPTY,
    source_family: :builtin
  )
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).



618
619
620
621
622
# File 'lib/rigor/analysis/check_rules.rb', line 618

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



624
625
626
627
628
629
# File 'lib/rigor/analysis/check_rules.rb', line 624

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.



429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/rigor/analysis/check_rules.rb', line 429

def filter_suppressed(diagnostics, comments:, disabled_rules:)
  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



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/rigor/analysis/check_rules.rb', line 376

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

  # Skip past leading `NilClass` writes when establishing
  # the canonical type. The common nullable-slot idiom
  # (`@x = nil` placeholder in `initialize` / a default
  # state slot, then `@x = :foo` on first concrete state)
  # would otherwise fire a false positive on every
  # concrete write because `first_class` was `NilClass`
  # and every subsequent `Symbol` / `String` / `Hash`
  # write triggered the divergence rule. The first
  # concrete (non-nil) write is the canonical type;
  # additional `NilClass` writes are still tolerated
  # downstream by the existing `other_class == "NilClass"`
  # check (the nullable-slot resets to nil between work).
  canonical = writes.find { |w| ivar_class_for(w[:type]) != "NilClass" }
  return [] if canonical.nil?

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

  canonical_index = writes.index(canonical)
  writes[(canonical_index + 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, ivar_writes) ⇒ 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.


289
290
291
292
293
294
295
# File 'lib/rigor/analysis/check_rules.rb', line 289

def ivar_write_mismatch_diagnostics(path, ivar_writes)
  ivar_writes.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

.known_suppression_token?(token) ⇒ Boolean

True when a suppression token resolves to a diagnostic identifier some producer can emit: a canonical CheckRules id, a legacy alias, the all wildcard, a family wildcard, a bare non-catalogue engine id, or a dotted id under a known non-check family (rbs_extended.*, dynamic.*, rbs.*, pre-eval.*, and any plugin.-prefixed id — plugins load dynamically, so their rule vocabulary cannot be enumerated here and under-warning is the FP-safe direction).

Returns:

  • (Boolean)


563
564
565
566
567
568
569
570
# File 'lib/rigor/analysis/check_rules.rb', line 563

def known_suppression_token?(token)
  return true if token == "all"
  return true if ALL_RULES.include?(token) || LEGACY_RULE_ALIASES.key?(token) ||
                 RULE_FAMILIES.include?(token) || NON_CHECK_DIAGNOSTIC_IDS.include?(token)

  family, rest = token.split(".", 2)
  !rest.nil? && NON_CHECK_DIAGNOSTIC_FAMILIES.include?(family)
end

.main_pass_node_diagnostics(path, node, scope_index) ⇒ Object

The verbatim per-node dispatch of the former inline main pass (diagnose's Source::NodeWalker.each case), now invoked by MainPassCollector on the shared RuleWalk. Returns the diagnostics for one node, in the same emission order as before.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/rigor/analysis/check_rules.rb', line 130

def main_pass_node_diagnostics(path, node, scope_index)
  case node
  when Prism::CallNode
    call_node_diagnostics(path, node, scope_index)
  when Prism::DefNode
    [
      return_type_mismatch_diagnostic(path, node, scope_index),
      override_visibility_diagnostic(path, node, scope_index),
      override_return_widened_diagnostic(path, node, scope_index),
      override_param_narrowed_diagnostic(path, node, scope_index)
    ].compact
  when Prism::IfNode, Prism::UnlessNode
    [unreachable_branch_diagnostic(path, node, scope_index)].compact
  else
    []
  end
end

.main_pass_oracle(path, root, scope_index) ⇒ Object

The former inline main pass, kept as the shadow oracle: walks the tree with Source::NodeWalker.each and accumulates the same per-node diagnostics in the same order MainPassCollector now produces them on the shared walk.



231
232
233
234
235
236
237
# File 'lib/rigor/analysis/check_rules.rb', line 231

def main_pass_oracle(path, root, scope_index)
  diagnostics = []
  Source::NodeWalker.each(root) do |node|
    diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
  end
  diagnostics
end

.node_collector_driver(collectors) ⇒ Object

A Rigor::Analysis::CheckRules::RuleWalk::CollectorDriver over a built-in collector set, for a foreign traversal to drive (ADR-53 B4). The driver visits each node and derives child contexts exactly as the standalone RuleWalk walk would.



172
173
174
# File 'lib/rigor/analysis/check_rules.rb', line 172

def node_collector_driver(collectors)
  RuleWalk::CollectorDriver.new(collectors.values)
end

.oracle_results(role, collector, path, root, scope_index) ⇒ Object

The oracle each hosted collector's walk result is checked against. The fact collectors re-run their legacy single-collector #collect walk; the main pass re-runs the former inline Source::NodeWalker case (main_pass_oracle) since its diagnostics are the result.



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

def oracle_results(role, collector, path, root, scope_index)
  return main_pass_oracle(path, root, scope_index) if role == :main_pass

  collector.class.new(scope_index).collect(root)
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.



471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/rigor/analysis/check_rules.rb', line 471

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.



67
68
69
70
71
72
73
# File 'lib/rigor/analysis/check_rules.rb', line 67

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

.return_in_ensure_diagnostics(path, results) ⇒ Object

v0.3.0 — flow.return-in-ensure. One diagnostic per explicit return lexically inside an ensure clause body: it silently discards the method's in-flight return value and swallows any in-flight exception. Purely syntactic; the Analysis::CheckRules::ReturnInEnsureCollector describes the frame-aware envelope (nested def / lambda / define_method blocks are excluded, plain blocks are not).



328
329
330
331
332
# File 'lib/rigor/analysis/check_rules.rb', line 328

def return_in_ensure_diagnostics(path, results)
  results.map do |result|
    build_return_in_ensure_diagnostic(path, result[:return_node])
  end
end

.run_node_collectors(path, root, scope_index) ⇒ Object

ADR-53 Track B — the RuleWalk-hosted built-in collectors (the main pass and the four fact collectors) all ride one traversal of the file instead of one walk each. Returns the populated collectors keyed by role so the caller can build the diagnostics from each collector's results. Used on the standalone path (no converged plugin walk); the converged path populates the same collector set via node_collector_driver instead.

Under RIGOR_SHADOW_RULE_WALK=1 each hosted collector's legacy single-collector #collect walk also runs as the oracle and any divergence aborts the run — the corpus-scale half of the equivalence harness (the curated half is rule_walk_equivalence_spec).



188
189
190
191
192
193
# File 'lib/rigor/analysis/check_rules.rb', line 188

def run_node_collectors(path, root, scope_index)
  collectors = build_node_collectors(path, scope_index)
  RuleWalk.run(root, collectors.values)
  shadow_verify_node_collectors(path, root, scope_index, collectors) if ENV["RIGOR_SHADOW_RULE_WALK"]
  collectors
end

.shadow_verify_converged_collectors(path, root, scope_index, collectors) ⇒ Object

ADR-53 B4 — corpus-scale oracle for the CONVERGED walk: the collectors (including the main pass, ADR-53 B3c) were populated by the Plugin::NodeRuleWalk traversal, not by RuleWalk.run, so re-run each collector's legacy oracle (the fact collectors' #collect walk, the main pass's inline Source::NodeWalker case) and assert the converged walk produced byte-identical results. Same divergence contract as shadow_verify_node_collectors; nil collectors (caller without built-in collection) is a no-op. path is threaded because the main pass's oracle carries it.



248
249
250
251
252
# File 'lib/rigor/analysis/check_rules.rb', line 248

def shadow_verify_converged_collectors(path, root, scope_index, collectors)
  return if collectors.nil?

  shadow_verify_node_collectors(path, root, scope_index, collectors)
end

.shadow_verify_node_collectors(path, root, scope_index, collectors) ⇒ Object



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

def shadow_verify_node_collectors(path, root, scope_index, collectors)
  divergences = collectors.filter_map do |role, collector|
    legacy = oracle_results(role, collector, path, root, scope_index)
    next if comparable(legacy) == comparable(collector.results)

    "#{role} legacy=#{legacy.size} walk=#{collector.results.size}"
  end
  return if divergences.empty?

  raise "RIGOR_SHADOW_RULE_WALK divergence: #{divergences.join('; ')}"
end

.shadowed_rescue_diagnostics(path, results) ⇒ Object

flow.shadowed-rescue-clause — one diagnostic per rescue clause every earlier-comparable class of which is already caught by an earlier clause of the same chain (see ShadowedRescueCollector for the ancestry-certainty envelope).



370
371
372
373
374
# File 'lib/rigor/analysis/check_rules.rb', line 370

def shadowed_rescue_diagnostics(path, results)
  results.map do |result|
    build_shadowed_rescue_diagnostic(path, result)
  end
end

.suppression_marker_diagnostics(path, comments) ⇒ Object

PHPStan-IgnoreParseErrorRule-modelled surveillance over the suppression markers themselves: a malformed or ineffective # rigor:disable / # rigor:disable-file comment must not silently no-op (the typo'd # rigor:disable call.undefined-metod suppresses nothing and the user never learns). Emits suppression.unknown-rule for every marker token that resolves to no known identifier, and suppression.empty for a marker that lists no rules at all. The MATCHING semantics are deliberately unchanged — an unknown token is still kept verbatim per the diagnostic-policy spec — so this is additive surveillance only. Both diagnostics run before filter_suppressed and flow through it, so they are themselves suppressible (# rigor:disable suppression.unknown-rule) with no regress: that token is known, so acknowledging it never re-fires the rule.



500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/rigor/analysis/check_rules.rb', line 500

def suppression_marker_diagnostics(path, comments)
  comments.each_with_object([]) do |comment, diagnostics|
    source = comment.location.slice
    if (match = FILE_SUPPRESSION_PATTERN.match(source))
      validate_suppression_tokens(match[:rules], "rigor:disable-file", path, comment, diagnostics)
    elsif (match = LINE_SUPPRESSION_PATTERN.match(source))
      validate_suppression_tokens(match[:rules], "rigor:disable", path, comment, diagnostics)
    else
      diagnose_bare_suppression_marker(path, comment, source, diagnostics)
    end
  end
end

.unknown_suppression_marker_diagnostic(path, comment, marker) ⇒ Object



586
587
588
589
590
591
592
593
594
595
596
597
598
# File 'lib/rigor/analysis/check_rules.rb', line 586

def unknown_suppression_marker_diagnostic(path, comment, marker)
  Diagnostic.new(
    path: path,
    line: comment.location.start_line,
    column: comment.location.start_column + 1,
    message: "unrecognised suppression marker `rigor:#{marker}` — Rigor's markers are " \
             "`# rigor:disable <rules>` (suppresses on its own line) and " \
             "`# rigor:disable-file <rules>`, so this comment suppresses nothing.",
    severity: :warning,
    rule: RULE_SUPPRESSION_UNKNOWN_MARKER,
    source_family: :builtin
  )
end

.unknown_suppression_rule_diagnostic(path, comment, marker, token) ⇒ Object



572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'lib/rigor/analysis/check_rules.rb', line 572

def unknown_suppression_rule_diagnostic(path, comment, marker, token)
  Diagnostic.new(
    path: path,
    line: comment.location.start_line,
    column: comment.location.start_column + 1,
    message: "unknown rule `#{token}` in `# #{marker}` — the token matches no known rule, alias, " \
             "or family, so this suppression has no effect. Likely a typo; `rigor explain <rule>` " \
             "lists the canonical ids.",
    severity: :warning,
    rule: RULE_SUPPRESSION_UNKNOWN_RULE,
    source_family: :builtin
  )
end

.unreachable_clause_diagnostics(path, results) ⇒ Object

ADR-47 — flow.unreachable-clause. One diagnostic per when clause the flow engine's narrowing proves can never match (its narrowed subject is bot). The squiggle lands on the dead clause's body, mirroring flow.unreachable-branch.



351
352
353
354
355
# File 'lib/rigor/analysis/check_rules.rb', line 351

def unreachable_clause_diagnostics(path, results)
  results.map do |result|
    build_unreachable_clause_diagnostic(path, result)
  end
end

.validate_suppression_tokens(raw, marker, path, comment, diagnostics) ⇒ Object



513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/rigor/analysis/check_rules.rb', line 513

def validate_suppression_tokens(raw, marker, path, comment, diagnostics)
  tokens = raw.to_s.split(/[\s,]+/).reject(&:empty?)
  if tokens.empty?
    diagnostics << empty_suppression_diagnostic(path, comment, marker)
    return
  end

  tokens.each do |token|
    next if known_suppression_token?(token)

    diagnostics << unknown_suppression_rule_diagnostic(path, comment, marker, token)
  end
end

.void_value_use_diagnostics(path, root, scope_index) ⇒ Object

ADR-100 WD2 — static.value-use.void. Runs a standalone walk (like self_undefined_method_diagnostics) over root, so its value-context slot inspection stays independent of the shared per-node RuleWalk. Each result is a value-context use of a call whose author-declared -> void return the engine recovered to top.



361
362
363
364
365
# File 'lib/rigor/analysis/check_rules.rb', line 361

def void_value_use_diagnostics(path, root, scope_index)
  VoidValueUseCollector.new(scope_index).collect(root).map do |result|
    build_void_value_use_diagnostic(path, result)
  end
end

Instance Method Details

#self?.build_node_collectorsHash[Symbol, untyped]

Parameters:

  • path (String)
  • scope_index (Object)

Returns:

  • (Hash[Symbol, untyped])


5
# File 'sig/rigor/analysis/fact_store.rbs', line 5

def self?.build_node_collectors: (String path, untyped scope_index) -> Hash[Symbol, untyped]

#self?.diagnoseArray[Diagnostic]

Parameters:

  • path: (String)
  • root: (Object)
  • scope_index: (Hash[untyped, Scope])

Returns:



4
# File 'sig/rigor/analysis/fact_store.rbs', line 4

def self?.diagnose: (path: String, root: untyped, scope_index: Hash[untyped, Scope]) -> Array[Diagnostic]

#self?.node_collector_driverObject

Parameters:

  • collectors (Hash[Symbol, untyped])

Returns:

  • (Object)


6
# File 'sig/rigor/analysis/fact_store.rbs', line 6

def self?.node_collector_driver: (Hash[Symbol, untyped] collectors) -> untyped

#self?.shadow_verify_converged_collectorsvoid

This method returns an undefined value.

Parameters:

  • path (String)
  • root (Object)
  • scope_index (Object)
  • collectors (Hash[Symbol, untyped], nil)


7
# File 'sig/rigor/analysis/fact_store.rbs', line 7

def self?.shadow_verify_converged_collectors: (String path, untyped root, untyped scope_index, Hash[Symbol, untyped]? collectors) -> void