Raise error if infinite correction loop

This commit is contained in:
fn ⌃ ⌥ 2021-10-26 13:05:22 -07:00
parent 16608965f5
commit 437584f9db
3 changed files with 277 additions and 7 deletions

View file

@ -8,6 +8,13 @@ module Ameba
config.update_rule ErrorRule.rule_name, enabled: false config.update_rule ErrorRule.rule_name, enabled: false
config.update_rule PerfRule.rule_name, enabled: false config.update_rule PerfRule.rule_name, enabled: false
config.update_rule AtoAA.rule_name, enabled: false
config.update_rule AtoB.rule_name, enabled: false
config.update_rule BtoA.rule_name, enabled: false
config.update_rule BtoC.rule_name, enabled: false
config.update_rule CtoA.rule_name, enabled: false
config.update_rule ClassToModule.rule_name, enabled: false
config.update_rule ModuleToClass.rule_name, enabled: false
Runner.new(config) Runner.new(config)
end end
@ -54,6 +61,15 @@ module Ameba
Runner.new(all_rules, [source], formatter, default_severity).run.success?.should be_true Runner.new(all_rules, [source], formatter, default_severity).run.success?.should be_true
end end
it "aborts because of an infinite loop" do
rules = [AtoAA.new] of Rule::Base
source = Source.new "class A; end", "source.cr"
message = "Infinite loop in source.cr caused by Ameba/AtoAA"
expect_raises(Runner::InfiniteCorrectionLoopError, message) do
Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run
end
end
context "exception in rule" do context "exception in rule" do
it "raises an exception raised in fiber while running a rule" do it "raises an exception raised in fiber while running a rule" do
rule = RaiseRule.new rule = RaiseRule.new
@ -180,5 +196,56 @@ module Ameba
.run.success?.should be_true .run.success?.should be_true
end end
end end
describe "#run with rules autocorrecting each other" do
context "with two conflicting rules" do
context "if there is an offense in an inspected file" do
it "aborts because of an infinite loop" do
rules = [AtoB.new, BtoA.new]
source = Source.new "class A; end", "source.cr"
message = "Infinite loop in source.cr caused by Ameba/AtoB -> Ameba/BtoA"
expect_raises(Runner::InfiniteCorrectionLoopError, message) do
Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run
end
end
end
context "if there are multiple offenses in an inspected file" do
it "aborts because of an infinite loop" do
rules = [AtoB.new, BtoA.new]
source = Source.new %(
class A; end
class A_A; end
), "source.cr"
message = "Infinite loop in source.cr caused by Ameba/AtoB -> Ameba/BtoA"
expect_raises(Runner::InfiniteCorrectionLoopError, message) do
Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run
end
end
end
end
context "with two pairs of conflicting rules" do
it "aborts because of an infinite loop" do
rules = [ClassToModule.new, ModuleToClass.new, AtoB.new, BtoA.new]
source = Source.new "class A_A; end", "source.cr"
message = "Infinite loop in source.cr caused by Ameba/ClassToModule, Ameba/AtoB -> Ameba/ModuleToClass, Ameba/BtoA"
expect_raises(Runner::InfiniteCorrectionLoopError, message) do
Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run
end
end
end
context "with three rule cycle" do
it "aborts because of an infinite loop" do
rules = [AtoB.new, BtoC.new, CtoA.new]
source = Source.new "class A; end", "source.cr"
message = "Infinite loop in source.cr caused by Ameba/AtoB -> Ameba/BtoC -> Ameba/CtoA"
expect_raises(Runner::InfiniteCorrectionLoopError, message) do
Runner.new(rules, [source], formatter, default_severity, autocorrect: true).run
end
end
end
end
end end
end end

View file

@ -95,6 +95,133 @@ module Ameba
end end
end end
class AtoAA < Rule::Base
include AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ClassDef | Crystal::ModuleDef)
return unless (name = node_source(node.name, source.lines))
return unless name.includes?("A")
issue_for(node.name, message: "A to AA") do |corrector|
corrector.replace(node.name, name.sub("A", "AA"))
end
end
end
class AtoB < Rule::Base
include AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ClassDef | Crystal::ModuleDef)
return unless (name = node_source(node.name, source.lines))
return unless name.includes?("A")
issue_for(node.name, message: "A to B") do |corrector|
corrector.replace(node.name, name.tr("A", "B"))
end
end
end
class BtoA < Rule::Base
include AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ClassDef | Crystal::ModuleDef)
return unless (name = node_source(node.name, source.lines))
return unless name.includes?("B")
issue_for(node.name, message: "B to A") do |corrector|
corrector.replace(node.name, name.tr("B", "A"))
end
end
end
class BtoC < Rule::Base
include AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ClassDef | Crystal::ModuleDef)
return unless (name = node_source(node.name, source.lines))
return unless name.includes?("B")
issue_for(node.name, message: "B to C") do |corrector|
corrector.replace(node.name, name.tr("B", "C"))
end
end
end
class CtoA < Rule::Base
include AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ClassDef | Crystal::ModuleDef)
return unless (name = node_source(node.name, source.lines))
return unless name.includes?("C")
issue_for(node.name, message: "C to A") do |corrector|
corrector.replace(node.name, name.tr("C", "A"))
end
end
end
class ClassToModule < Ameba::Rule::Base
include Ameba::AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ClassDef)
return unless (location = node.location)
end_location = Crystal::Location.new(
location.filename,
location.line_number,
location.column_number + "class".size - 1
)
issue_for(location, end_location, message: "class to module") do |corrector|
corrector.replace(location, end_location, "module")
end
end
end
class ModuleToClass < Ameba::Rule::Base
include Ameba::AST::Util
properties do
description "This rule is only used to test infinite loop detection"
end
def test(source, node : Crystal::ModuleDef)
return unless (location = node.location)
end_location = Crystal::Location.new(
location.filename,
location.line_number,
location.column_number + "module".size - 1
)
issue_for(location, end_location, message: "module to class") do |corrector|
corrector.replace(location, end_location, "class")
end
end
end
class DummyFormatter < Formatter::BaseFormatter class DummyFormatter < Formatter::BaseFormatter
property started_sources : Array(Source)? property started_sources : Array(Source)?
property finished_sources : Array(Source)? property finished_sources : Array(Source)?

View file

@ -1,3 +1,5 @@
require "digest"
module Ameba module Ameba
# Represents a runner for inspecting sources files. # Represents a runner for inspecting sources files.
# Holds a list of rules to do inspection based on, # Holds a list of rules to do inspection based on,
@ -11,6 +13,25 @@ module Ameba
# ``` # ```
# #
class Runner class Runner
# An error indicating that the inspection loop got stuck correcting
# issues back and forth.
class InfiniteCorrectionLoopError < RuntimeError
def initialize(path, issues_by_iteration, loop_start = -1)
root_cause =
issues_by_iteration[loop_start..-1]
.map(&.map(&.rule.name).uniq!.join(", "))
.join(" -> ")
message = String.build do |io|
io << "Infinite loop"
io << " in " << path unless path.empty?
io << " caused by " << root_cause
end
super message
end
end
# A list of rules to do inspection based on. # A list of rules to do inspection based on.
@rules : Array(Rule::Base) @rules : Array(Rule::Base)
@ -52,8 +73,7 @@ module Ameba
.find &.name.==(Rule::Lint::UnneededDisableDirective.rule_name) .find &.name.==(Rule::Lint::UnneededDisableDirective.rule_name)
end end
protected def initialize(@rules, @sources, @formatter, @severity) protected def initialize(@rules, @sources, @formatter, @severity, @autocorrect = false)
@autocorrect = false
end end
# Performs the inspection. Iterates through all sources and test it using # Performs the inspection. Iterates through all sources and test it using
@ -93,8 +113,18 @@ module Ameba
private def run_source(source) private def run_source(source)
@formatter.source_started source @formatter.source_started source
corrected_issues = [] of Issue # This variable is a 2D array used to track corrected issues after each
loop do # inspection iteration. This is used to output meaningful infinite loop
# error message.
corrected_issues = [] of Array(Issue)
# When running with --fix, we need to inspect the source until no more
# corrections are made (because automatic corrections can introduce new
# issues). In the normal case the loop is only executed once.
loop_unless_infinite(source, corrected_issues) do
# We have to reprocess the source to pick up any changes. Since a
# change could (theoretically) introduce syntax errors, we break the
# loop if we find any.
@syntax_rule.test(source) @syntax_rule.test(source)
break unless source.valid? break unless source.valid?
@ -105,12 +135,18 @@ module Ameba
check_unneeded_directives(source) check_unneeded_directives(source)
break unless autocorrect? && source.correct break unless autocorrect? && source.correct
corrected_issues += source.issues.select(&.correctable?) # The issues that couldn't be corrected will be found again so we
# only keep the corrected ones in order to avoid duplicate reporting.
corrected_issues << source.issues.select(&.correctable?)
source.issues.clear source.issues.clear
end end
corrected_issues.reverse_each { |issue| source.issues.unshift(issue) }
File.write(source.path, source.code) unless corrected_issues.empty?
corrected_issues.flatten.reverse_each do |issue|
source.issues.unshift(issue)
end
File.write(source.path, source.code) unless corrected_issues.empty?
ensure
@formatter.source_finished source @formatter.source_finished source
end end
@ -144,6 +180,46 @@ module Ameba
end end
end end
private MAX_ITERATIONS = 200
private def loop_unless_infinite(source, corrected_issues)
# Keep track of the state of the source. If a rule modifies the source
# and another rule undoes it producing identical source we have an
# infinite loop.
processed_sources = [] of String
# It is possible for a rule to keep adding indefinitely to a file,
# making it bigger and bigger. If the inspection loop runs for an
# excessively high number of iterations, this is likely happening.
iterations = 0
loop do
check_for_infinite_loop(source, corrected_issues, processed_sources)
if (iterations += 1) > MAX_ITERATIONS
raise InfiniteCorrectionLoopError.new(source.path, corrected_issues)
end
yield
end
end
# Check whether a run created source identical to a previous run, which
# means that we definitely have an infinite loop.
private def check_for_infinite_loop(source, corrected_issues, processed_sources)
checksum = Digest::SHA1.hexdigest(source.code)
if (loop_start = processed_sources.index(checksum))
raise InfiniteCorrectionLoopError.new(
source.path,
corrected_issues,
loop_start: loop_start
)
end
processed_sources << checksum
end
private def check_unneeded_directives(source) private def check_unneeded_directives(source)
if (rule = @unneeded_disable_directive_rule) && rule.enabled if (rule = @unneeded_disable_directive_rule) && rule.enabled
rule.test(source) rule.test(source)