Module: Rigor::Analysis::CheckRules
- Defined in:
- lib/rigor/analysis/check_rules.rb,
lib/rigor/analysis/check_rules/ivar_write_collector.rb,
lib/rigor/analysis/check_rules/dead_assignment_collector.rb,
lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb
Overview
First-preview catalogue of ‘rigor check` diagnostic rules.
The rules are intentionally narrow: they fire ONLY when the engine is confident enough to make a useful claim, and they MUST NOT raise on unrecognised AST shapes, RBS gaps, or missing scope information. Each rule consumes the per-node scope index produced by ‘Rigor::Inference::ScopeIndexer.index` and yields zero or more `Rigor::Analysis::Diagnostic` values.
The first shipped rule, ‘UndefinedMethodOnTypedReceiver`, flags an explicit-receiver `Prism::CallNode` whose receiver statically resolves to a `Type::Nominal` or `Type::Singleton` known to the analyzer’s RBS environment AND whose method name does not appear on that class’s instance / singleton method table. This is the canonical “type check” signal (“Foo has no method bar”), but it explicitly does NOT fire for:
-
implicit-self calls (no ‘node.receiver`) — too noisy without per-method RBS for every helper in the class.
-
dynamic / unknown receivers (‘Dynamic`, `Top`, `Union`) — by definition we cannot enumerate the method set.
-
shape carriers (‘Tuple`, `HashShape`, `Constant`) — their dispatch goes through `ShapeDispatch` / `ConstantFolding` which the rule does not yet model.
-
receivers whose class name is NOT registered in the loader (RBS-blind environments, unknown stdlib).
The above list is the deliberate conservative envelope of the first preview; later slices broaden it. rubocop:disable Metrics/ModuleLength
Defined Under Namespace
Classes: AlwaysTruthyConditionCollector, DeadAssignmentCollector, IvarWriteCollector
Constant Summary collapse
- RULE_UNDEFINED_METHOD =
Canonical identifiers for each rule. Per ADR-8 §“Diagnostic ID family hierarchy”, rule names are ‘family.rule-name` two-segment strings; the families group diagnostics by where they originate (`call.*` for call-site rules, `flow.*` for flow-analysis proofs, `assert.*` for runtime-assertion rules, `dump.*` for debug helpers, `def.*` for method-definition rules). Used by the configuration `disable:` list and the in-source `# rigor:disable <rule>` suppression comment system; new rules MUST register here so user configuration can refer to them.
"call.undefined-method"- RULE_WRONG_ARITY =
"call.wrong-arity"- RULE_ARGUMENT_TYPE =
"call.argument-type-mismatch"- RULE_NIL_RECEIVER =
"call.possible-nil-receiver"- RULE_DUMP_TYPE =
"dump.type"- RULE_ASSERT_TYPE =
"assert.type-mismatch"- RULE_ALWAYS_RAISES =
"flow.always-raises"- RULE_UNREACHABLE_BRANCH =
"flow.unreachable-branch"- RULE_RETURN_TYPE =
"def.return-type-mismatch"- RULE_VISIBILITY_MISMATCH =
"def.method-visibility-mismatch"- RULE_IVAR_WRITE_MISMATCH =
"def.ivar-write-mismatch"- RULE_DEAD_ASSIGNMENT =
"flow.dead-assignment"- RULE_ALWAYS_TRUTHY_CONDITION =
"flow.always-truthy-condition"- ALL_RULES =
[ RULE_UNDEFINED_METHOD, RULE_WRONG_ARITY, RULE_ARGUMENT_TYPE, RULE_NIL_RECEIVER, RULE_DUMP_TYPE, RULE_ASSERT_TYPE, RULE_ALWAYS_RAISES, RULE_UNREACHABLE_BRANCH, RULE_DEAD_ASSIGNMENT, RULE_ALWAYS_TRUTHY_CONDITION, RULE_RETURN_TYPE, RULE_VISIBILITY_MISMATCH, RULE_IVAR_WRITE_MISMATCH ].freeze
- LEGACY_RULE_ALIASES =
Backward-compat alias table (ADR-8 § “Backward compatibility”). Existing user code with ‘# rigor:disable undefined-method` / `disable: [undefined-method]` keeps working — the legacy unprefixed identifiers map to their canonical `family.rule-name` form here. Removing the aliases is a future ADR once user code has migrated; until then, both spellings resolve identically.
{ "undefined-method" => RULE_UNDEFINED_METHOD, "wrong-arity" => RULE_WRONG_ARITY, "argument-type-mismatch" => RULE_ARGUMENT_TYPE, "possible-nil-receiver" => RULE_NIL_RECEIVER, "dump-type" => RULE_DUMP_TYPE, "assert-type" => RULE_ASSERT_TYPE, "always-raises" => RULE_ALWAYS_RAISES, "unreachable-branch" => RULE_UNREACHABLE_BRANCH, "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH, "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH, "dead-assignment" => RULE_DEAD_ASSIGNMENT, "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION }.freeze
- RULE_FAMILIES =
Family wildcard — a ‘<family>` token in a suppression comment or `disable:` list disables every rule whose canonical id starts with `<family>.`. Per ADR-8 § “1”.
%w[call flow assert dump def].freeze
Class Method Summary collapse
- .absorb_suppression_tokens(raw, target) ⇒ Object
-
.always_truthy_condition_diagnostics(path, root, scope_index) ⇒ Object
v0.1.2 — ‘flow.always-truthy-condition`.
- .call_node_diagnostics(path, node, scope_index) ⇒ Object
-
.dead_assignment_diagnostics(path, root, scope_index) ⇒ Object
v0.1.2 — ‘flow.dead-assignment`.
-
.diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) ⇒ Array<Rigor::Analysis::Diagnostic>
Yields diagnostics for every unrecognised method call on a typed receiver in ‘root`’s subtree.
-
.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, root, scope_index) ⇒ Object
v0.1.2 — ‘def.ivar-write-mismatch`.
-
.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 wildcard `call`) to the set of canonical rule identifiers it disables.
Class Method Details
.absorb_suppression_tokens(raw, target) ⇒ Object
304 305 306 307 308 |
# File 'lib/rigor/analysis/check_rules.rb', line 304 def absorb_suppression_tokens(raw, target) raw.to_s.split(/[\s,]+/).reject(&:empty?).each do |token| target.merge((token)) end end |
.always_truthy_condition_diagnostics(path, root, scope_index) ⇒ Object
v0.1.2 — ‘flow.always-truthy-condition`. Fires on `if` / `unless` / ternary predicates whose inferred type is a `Type::Constant` AND that don’t fall in the literal-only / inside-loop-or-block / defensive- predicate skip envelope (see ‘Analysis::CheckRules::AlwaysTruthyConditionCollector` for the full triage rationale).
222 223 224 225 226 |
# File 'lib/rigor/analysis/check_rules.rb', line 222 def always_truthy_condition_diagnostics(path, root, scope_index) AlwaysTruthyConditionCollector.new(scope_index).collect(root).map do |result| build_always_truthy_condition_diagnostic(path, result.node, result.polarity) end end |
.call_node_diagnostics(path, node, scope_index) ⇒ Object
162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/rigor/analysis/check_rules.rb', line 162 def call_node_diagnostics(path, node, scope_index) [ undefined_method_diagnostic(path, node, scope_index), wrong_arity_diagnostic(path, node, scope_index), argument_type_diagnostic(path, node, scope_index), nil_receiver_diagnostic(path, node, scope_index), dump_type_diagnostic(path, node, scope_index), assert_type_diagnostic(path, node, scope_index), always_raises_diagnostic(path, node, scope_index), visibility_mismatch_diagnostic(path, node, scope_index) ].compact end |
.dead_assignment_diagnostics(path, root, scope_index) ⇒ Object
v0.1.2 — ‘flow.dead-assignment`. Walks every `DefNode` body and emits a diagnostic for each plain `LocalVariableWriteNode` whose target name is never read in the same body. The `Analysis::CheckRules::DeadAssignmentCollector` describes the conservative envelope.
209 210 211 212 213 |
# File 'lib/rigor/analysis/check_rules.rb', line 209 def dead_assignment_diagnostics(path, root, scope_index) DeadAssignmentCollector.new(scope_index).collect(root).map do |result| build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node]) end end |
.diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) ⇒ Array<Rigor::Analysis::Diagnostic>
Yields diagnostics for every unrecognised method call on a typed receiver in ‘root`’s subtree. The caller MUST have already produced ‘scope_index` through `Rigor::Inference::ScopeIndexer.index(root, default_scope:)`.
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/rigor/analysis/check_rules.rb', line 142 def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) diagnostics = [] Source::NodeWalker.each(root) do |node| case node when Prism::CallNode diagnostics.concat(call_node_diagnostics(path, node, scope_index)) when Prism::DefNode return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index) diagnostics << return_diagnostic if return_diagnostic when Prism::IfNode, Prism::UnlessNode unreachable = unreachable_branch_diagnostic(path, node, scope_index) diagnostics << unreachable if unreachable end end diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index)) diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index)) diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index)) filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules) end |
.expand_rule_tokens(tokens) ⇒ Object
Expands a list of user-supplied rule tokens into the canonical-id set per ADR-8 § “Backward compatibility”. ‘disabled_rules` accepts unprefixed legacy names (`undefined-method`), canonical names (`call.undefined-method`), and family wildcards (`call`).
315 316 317 318 319 |
# File 'lib/rigor/analysis/check_rules.rb', line 315 def (tokens) Array(tokens).each_with_object(Set.new) do |token, set| set.merge((token.to_s)) end end |
.expand_token(token) ⇒ Object
321 322 323 324 325 326 |
# File 'lib/rigor/analysis/check_rules.rb', line 321 def (token) return ["all"] if token == "all" resolved = resolve_rule_token(token) resolved.nil? || resolved.empty? ? [token] : resolved end |
.filter_suppressed(diagnostics, comments:, disabled_rules:) ⇒ Object
v0.0.2 #6 — diagnostic suppression. Three kinds of suppression compose:
-
Project-level: ‘disabled_rules` is the project’s ‘.rigor.yml` `disable:` list. Any diagnostic whose `rule` is in the list is dropped.
-
File-level (v0.1.2): ‘# rigor:disable-file <rule1>, <rule2>` anywhere in the file suppresses the matching diagnostic for every line. `# rigor:disable-file all` suppresses every rule across the file. Convention is to put the comment near the top, but Rigor accepts it anywhere — the comment scope is “this file” regardless of position.
-
**In-source line**: ‘# rigor:disable <rule1>, <rule2>` on the same line as the offending expression suppresses the matching diagnostic for that line only. `# rigor:disable all` on a line suppresses every rule on that line.
Diagnostics with ‘rule == nil` (parse errors, path errors, internal analyzer errors) are NEVER suppressed — they represent failures the user cannot silence away.
265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/rigor/analysis/check_rules.rb', line 265 def filter_suppressed(diagnostics, comments:, disabled_rules:) # rubocop:disable Metrics/CyclomaticComplexity line_suppressions, file_suppressions = parse_suppression_comments(comments) disabled = (disabled_rules) diagnostics.reject do |diagnostic| rule = diagnostic.rule next false if rule.nil? next true if disabled.include?(rule) next true if file_suppressions.include?("all") || file_suppressions.include?(rule) line_rules = line_suppressions[diagnostic.line] line_rules && (line_rules.include?("all") || line_rules.include?(rule)) end end |
.ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes) ⇒ Object
228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/rigor/analysis/check_rules.rb', line 228 def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes) return [] if writes.size < 2 first_class = ivar_class_for(writes.first[:type]) return [] if first_class.nil? writes[1..].filter_map do |write| other_class = ivar_class_for(write[:type]) next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class build_ivar_write_mismatch_diagnostic(path, write[:node], class_name, ivar_name, first_class, other_class) end end |
.ivar_write_mismatch_diagnostics(path, root, scope_index) ⇒ Object
v0.1.2 — ‘def.ivar-write-mismatch`. Walks every ClassNode / ModuleNode body, gathers per-class ivar writes with their rvalue types, and emits a diagnostic when a later write’s concrete class disagrees with the first write’s. The first write per (class, ivar) is treated as the “declared” type; subsequent writes that land on a different concrete class trigger.
Conservative envelope:
-
Only fires when both the first and the offending write resolve to a ‘concrete_class_name` (Nominal / Singleton / Constant / Tuple → “Array” / HashShape →“Hash”). Unions / Dynamic / IntegerRange / shape- varied carriers fall through.
-
‘NilClass` is an intentional widening idiom (`@x = “value”` then later `@x = nil` to “clear”) — skipped.
-
Singleton-method (‘def self.foo`) bodies are skipped. Class-level ivars (`@x = 1` outside any def, in the class body) are also skipped — they’re a separate surface (‘Module#@var`) the engine doesn’t yet model.
195 196 197 198 199 200 201 |
# File 'lib/rigor/analysis/check_rules.rb', line 195 def ivar_write_mismatch_diagnostics(path, root, scope_index) IvarWriteCollector.new(scope_index).collect(root).flat_map do |class_name, writes_by_ivar| writes_by_ivar.flat_map do |ivar_name, writes| ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes) end end end |
.parse_suppression_comments(comments) ⇒ Array<(Hash{Integer => Set}, Set)>
Returns pair of ‘(line_suppressions, file_suppressions)`. Line suppressions are keyed by source line number; file suppressions apply to every line.
290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'lib/rigor/analysis/check_rules.rb', line 290 def parse_suppression_comments(comments) line_suppressions = Hash.new { |h, k| h[k] = Set.new } file_suppressions = Set.new comments.each do |comment| source = comment.location.slice if (match = FILE_SUPPRESSION_PATTERN.match(source)) absorb_suppression_tokens(match[:rules], file_suppressions) elsif (match = LINE_SUPPRESSION_PATTERN.match(source)) absorb_suppression_tokens(match[:rules], line_suppressions[comment.location.start_line]) end end [line_suppressions, file_suppressions] end |
.resolve_rule_token(token) ⇒ Object
Resolves a user-supplied rule token (‘undefined-method`, `call.undefined-method`, or the family wildcard `call`) to the set of canonical rule identifiers it disables. Returns `nil` for `“all”` (the existing wildcard meaning “every rule”), or for unknown tokens.
122 123 124 125 126 127 128 |
# File 'lib/rigor/analysis/check_rules.rb', line 122 def self.resolve_rule_token(token) return nil if token == "all" return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token) return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token) ALL_RULES.include?(token) ? [token] : [] end |