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_nameresolves 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-reducedancestor 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-nametwo-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 configurationdisable: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.rbso RuleCatalog — which the CLI's JSONevidence_tier/documentation_urlenrichment 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-> voidreturn, used in value context. Authored:warning, resolved:offby every profile and promoted to:warningonly by theuse-of-void-valuebleeding-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 canonicalfamily.rule-nameform 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 ordisable: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 theplugin.prefix reserved for plugin-produced identifiers.suppression.unknown-ruletreats 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 inAnalysis::Runner/Runner::DiagnosticAggregator). A token equal to one of these is treated as known bysuppression.unknown-ruleeven 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
- .absorb_suppression_tokens(raw, target) ⇒ Object
-
.always_truthy_condition_diagnostics(path, results) ⇒ Object
v0.1.2 —
flow.always-truthy-condition. -
.build_node_collectors(path, scope_index) ⇒ Object
Constructs the fresh, unpopulated built-in collector set keyed by role, including the main pass.
- .call_node_diagnostics(path, node, scope_index) ⇒ Object
-
.comparable(results) ⇒ Object
Normalises a collector's result for value comparison.
-
.dead_assignment_diagnostics(path, dead_assignments) ⇒ Object
v0.1.2 —
flow.dead-assignment. -
.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. -
.diagnose_bare_suppression_marker(path, comment, source, diagnostics) ⇒ Object
A comment carrying the marker word but not the token-bearing suppression grammar.
-
.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). -
.duplicate_hash_key_diagnostics(path, duplicate_keys) ⇒ Object
v0.3.0 —
flow.duplicate-hash-key. - .empty_suppression_diagnostic(path, comment, marker) ⇒ Object
-
.expand_rule_tokens(tokens) ⇒ Object
Expands a list of user-supplied rule tokens into the canonical-id set per ADR-8 § "Backward compatibility".
- .expand_token(token) ⇒ Object
-
.filter_suppressed(diagnostics, comments:, disabled_rules:) ⇒ Object
v0.0.2 #6 — diagnostic suppression.
- .ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes) ⇒ Object
-
.ivar_write_mismatch_diagnostics(path, ivar_writes) ⇒ Object
v0.1.2 —
def.ivar-write-mismatch. -
.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
allwildcard, 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 anyplugin.-prefixed id — plugins load dynamically, so their rule vocabulary cannot be enumerated here and under-warning is the FP-safe direction). -
.main_pass_node_diagnostics(path, node, scope_index) ⇒ Object
The verbatim per-node dispatch of the former inline main pass (
diagnose'sSource::NodeWalker.eachcase), now invoked by MainPassCollector on the shared RuleWalk. -
.main_pass_oracle(path, root, scope_index) ⇒ Object
The former inline main pass, kept as the shadow oracle: walks the tree with
Source::NodeWalker.eachand accumulates the same per-node diagnostics in the same order MainPassCollector now produces them on the shared walk. -
.node_collector_driver(collectors) ⇒ Object
A RuleWalk::CollectorDriver over a built-in collector set, for a foreign traversal to drive (ADR-53 B4).
-
.oracle_results(role, collector, path, root, scope_index) ⇒ Object
The oracle each hosted collector's walk result is checked against.
-
.parse_suppression_comments(comments) ⇒ Array<(Hash{Integer => Set}, Set)>
Pair of
(line_suppressions, file_suppressions). -
.resolve_rule_token(token) ⇒ Object
Resolves a user-supplied rule token (
undefined-method,call.undefined-method, or the family wildcardcall) to the set of canonical rule identifiers it disables. -
.return_in_ensure_diagnostics(path, results) ⇒ Object
v0.3.0 —
flow.return-in-ensure. -
.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.
-
.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'#collectwalk, the main pass's inlineSource::NodeWalkercase) and assert the converged walk produced byte-identical results. - .shadow_verify_node_collectors(path, root, scope_index, collectors) ⇒ Object
-
.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). -
.suppression_marker_diagnostics(path, comments) ⇒ Object
PHPStan-
IgnoreParseErrorRule-modelled surveillance over the suppression markers themselves: a malformed or ineffective# rigor:disable/# rigor:disable-filecomment must not silently no-op (the typo'd# rigor:disable call.undefined-metodsuppresses nothing and the user never learns). - .unknown_suppression_marker_diagnostic(path, comment, marker) ⇒ Object
- .unknown_suppression_rule_diagnostic(path, comment, marker, token) ⇒ Object
-
.unreachable_clause_diagnostics(path, results) ⇒ Object
ADR-47 —
flow.unreachable-clause. - .validate_suppression_tokens(raw, marker, path, comment, diagnostics) ⇒ Object
-
.void_value_use_diagnostics(path, root, scope_index) ⇒ Object
ADR-100 WD2 —
static.value-use.void.
Instance Method Summary collapse
- #self?.build_node_collectors ⇒ Hash[Symbol, untyped]
- #self?.diagnose ⇒ Array[Diagnostic]
- #self?.node_collector_driver ⇒ Object
- #self?.shadow_verify_converged_collectors ⇒ void
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((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.
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 (path, comment, source, diagnostics) = BARE_SUPPRESSION_MARKER.match(source) if return unless [:rest].match?(/\A[\s,]*\z/) marker = [: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 (tokens) Array(tokens).each_with_object(Set.new) do |token, set| set.merge((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 (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_rulesis the project's.rigor.ymldisable:list. Any diagnostic whoseruleis 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 allsuppresses 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 allon 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 = (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. NilClassis an intentional widening idiom (@x = "value"then later@x = nilto "clear") — skipped.- Singleton-method (
def self.foo) bodies are skipped. Class-level ivars (@x = 1outside 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).
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.
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 (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_collectors ⇒ 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?.diagnose ⇒ Array[Diagnostic]
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_driver ⇒ 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_collectors ⇒ void
This method returns an undefined value.
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
|