Module: Rigor::Analysis::CheckRules

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

Modules: RuleWalk Classes: AlwaysTruthyConditionCollector, DeadAssignmentCollector, IvarWriteCollector, MainPassCollector, SelfClosednessScanner, UnreachableClauseCollector

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_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_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"
ALL_RULES =
[
  RULE_UNDEFINED_METHOD,
  RULE_SELF_UNDEFINED_METHOD,
  RULE_UNRESOLVED_TOPLEVEL,
  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_UNREACHABLE_CLAUSE,
  RULE_RETURN_TYPE,
  RULE_VISIBILITY_MISMATCH,
  RULE_OVERRIDE_VISIBILITY_REDUCED,
  RULE_OVERRIDE_RETURN_WIDENED,
  RULE_OVERRIDE_PARAM_NARROWED,
  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,
  "self-undefined-method" => RULE_SELF_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,
  "unreachable-clause" => RULE_UNREACHABLE_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].freeze
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

Class Method Summary collapse

Class Method Details

.absorb_suppression_tokens(raw, target) ⇒ Object



481
482
483
484
485
# File 'lib/rigor/analysis/check_rules.rb', line 481

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



373
374
375
376
377
# File 'lib/rigor/analysis/check_rules.rb', line 373

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



216
217
218
219
220
221
222
223
224
# File 'lib/rigor/analysis/check_rules.rb', line 216

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),
    ivar_writes: IvarWriteCollector.new(scope_index),
    dead_assignments: DeadAssignmentCollector.new(scope_index)
  }
end

.call_node_diagnostics(path, node, scope_index) ⇒ Object



312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/rigor/analysis/check_rules.rb', line 312

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



269
270
271
272
273
# File 'lib/rigor/analysis/check_rules.rb', line 269

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.



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

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:

  • path (String)

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

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

Returns:



175
176
177
178
179
180
181
182
183
184
185
# File 'lib/rigor/analysis/check_rules.rb', line 175

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(always_truthy_condition_diagnostics(path, collectors[:always_truthy].results))
  diagnostics.concat(unreachable_clause_diagnostics(path, collectors[:unreachable_clauses].results))
  diagnostics.concat(ivar_write_mismatch_diagnostics(path, collectors[:ivar_writes].results))
  diagnostics.concat(dead_assignment_diagnostics(path, collectors[:dead_assignments].results))
  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`).



492
493
494
495
496
# File 'lib/rigor/analysis/check_rules.rb', line 492

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



498
499
500
501
502
503
# File 'lib/rigor/analysis/check_rules.rb', line 498

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.



442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/rigor/analysis/check_rules.rb', line 442

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



389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/rigor/analysis/check_rules.rb', line 389

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.



346
347
348
349
350
351
352
# File 'lib/rigor/analysis/check_rules.rb', line 346

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

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



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/rigor/analysis/check_rules.rb', line 191

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.



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

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.



230
231
232
# File 'lib/rigor/analysis/check_rules.rb', line 230

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.



279
280
281
282
283
# File 'lib/rigor/analysis/check_rules.rb', line 279

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.



467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'lib/rigor/analysis/check_rules.rb', line 467

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.



147
148
149
150
151
152
153
# File 'lib/rigor/analysis/check_rules.rb', line 147

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

.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`).



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

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.



306
307
308
309
310
# File 'lib/rigor/analysis/check_rules.rb', line 306

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



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

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

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



383
384
385
386
387
# File 'lib/rigor/analysis/check_rules.rb', line 383

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