diff --git a/lib/spoom/cli/srb/tc.rb b/lib/spoom/cli/srb/tc.rb index baeec0e6..a24b6ef5 100644 --- a/lib/spoom/cli/srb/tc.rb +++ b/lib/spoom/cli/srb/tc.rb @@ -25,6 +25,7 @@ class Tc < Thor option :junit_output_path, type: :string, desc: "Output failures to XML file formatted for JUnit" option :sorbet, type: :string, desc: "Path to custom Sorbet bin" option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet" + option :ignore_errors, type: :string, desc: "Path to ignored errors file (default: sorbet/ignored_errors.cfg)" def tc(*paths_to_select) context = context_requiring_sorbet! limit = options[:limit] @@ -36,7 +37,16 @@ def tc(*paths_to_select) junit_output_path = options[:junit_output_path] sorbet = options[:sorbet] - unless limit || code || sort + ignore_errors_path = options[:ignore_errors] + ignore_errors_path ||= Spoom::Sorbet::Errors::DEFAULT_IGNORED_ERRORS_PATH if File.exist?( + File.join(context.absolute_path, Spoom::Sorbet::Errors::DEFAULT_IGNORED_ERRORS_PATH), + ) + ignored_errors = if ignore_errors_path + path = File.expand_path(ignore_errors_path, context.absolute_path) + Spoom::Sorbet::Errors.parse_ignored_errors(path) + end + + unless limit || code || sort || ignored_errors result = T.unsafe(context).srb_tc( *options[:sorbet_options].split(" "), capture_err: false, @@ -84,6 +94,32 @@ def tc(*paths_to_select) end end + ignored_count = 0 + if ignored_errors + matched = Set.new #: Set[[Integer, String, Integer]] + active, ignored = errors.partition do |e| + err_code = e.code + err_file = e.file + err_line = e.line + if err_code && err_file && err_line && ignored_errors.include?([err_code, err_file, err_line]) + matched << [err_code, err_file, err_line] + false + else + true + end + end + ignored_count = ignored.size + errors = active + + stale = ignored_errors - matched + unless stale.empty? + stale.each do |entry| + say_error("Stale entry in ignore file: #{entry[0]}:#{entry[1]}:#{entry[2]}", status: nil) + end + exit(1) + end + end + errors = case sort when SORT_CODE Spoom::Sorbet::Errors.sort_errors_by_code(errors) @@ -110,14 +146,20 @@ def tc(*paths_to_select) end if count - if errors_count == errors.size + parts = [] + if errors_count != errors.size + ignored_count + parts << "#{errors.size} shown" + end + parts << "#{ignored_count} ignored" if ignored_count > 0 + if parts.empty? say_error("Errors: #{errors_count}", status: nil) else - say_error("Errors: #{errors.size} shown, #{errors_count} total", status: nil) + parts << "#{errors_count} total" + say_error("Errors: #{parts.join(", ")}", status: nil) end end - exit(1) + exit(errors.empty? ? 0 : 1) rescue Spoom::Sorbet::Error::Segfault => error say_error(<<~ERR, status: nil) #{red("!!! Sorbet exited with code #{error.result.exit_code} - SEGFAULT !!!")} diff --git a/lib/spoom/sorbet/errors.rb b/lib/spoom/sorbet/errors.rb index 17ef9aff..26a494f0 100644 --- a/lib/spoom/sorbet/errors.rb +++ b/lib/spoom/sorbet/errors.rb @@ -8,12 +8,32 @@ module Sorbet module Errors DEFAULT_ERROR_URL_BASE = "https://srb.help/" + DEFAULT_IGNORED_ERRORS_PATH = "sorbet/ignored_errors.cfg" + class << self #: (Array[Error] errors) -> Array[Error] def sort_errors_by_code(errors) errors.sort_by { |e| [e.code, e.file, e.line, e.message] } end + #: (String path) -> Set[[Integer, String, Integer]] + def parse_ignored_errors(path) + ignored = Set.new #: Set[[Integer, String, Integer]] + File.foreach(path) do |line| + line = line.strip + next if line.empty? || line.start_with?("#") + + parts = line.split(":") + next if parts.size < 3 + + code = parts[0].to_i + file_line = parts[-1].to_i + file_path = parts[1..-2].join(":") + ignored << [code, file_path, file_line] + end + ignored + end + #: (Array[Error]) -> REXML::Document def to_junit_xml(errors) testsuite_element = REXML::Element.new("testsuite") diff --git a/rbi/spoom.rbi b/rbi/spoom.rbi index 13bdac0d..cfe2b8c1 100644 --- a/rbi/spoom.rbi +++ b/rbi/spoom.rbi @@ -2652,6 +2652,9 @@ class Spoom::Sorbet::Error::Segfault < ::Spoom::Sorbet::Error; end module Spoom::Sorbet::Errors class << self + sig { params(path: ::String).returns(T::Set[[::Integer, ::String, ::Integer]]) } + def parse_ignored_errors(path); end + sig { params(errors: T::Array[::Spoom::Sorbet::Errors::Error]).returns(T::Array[::Spoom::Sorbet::Errors::Error]) } def sort_errors_by_code(errors); end @@ -2661,6 +2664,7 @@ module Spoom::Sorbet::Errors end Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE = T.let(T.unsafe(nil), String) +Spoom::Sorbet::Errors::DEFAULT_IGNORED_ERRORS_PATH = T.let(T.unsafe(nil), String) class Spoom::Sorbet::Errors::Error include ::Comparable diff --git a/test/spoom/cli/srb/tc_test.rb b/test/spoom/cli/srb/tc_test.rb index 6f0d1d9a..8ed9a7b2 100644 --- a/test/spoom/cli/srb/tc_test.rb +++ b/test/spoom/cli/srb/tc_test.rb @@ -461,6 +461,83 @@ def test_display_sorbet_error refute(result.status) end + def test_ignore_errors_all_ignored + @project.write!("sorbet/ignored_errors.cfg", <<~CFG) + # Ignore all errors in errors/errors.rb + 5002:errors/errors.rb:5 + 7003:errors/errors.rb:5 + 7004:errors/errors.rb:10 + 7003:errors/errors.rb:11 + 7004:errors/errors.rb:11 + CFG + result = @project.spoom("srb tc --no-color") + assert_equal(<<~MSG, result.err) + Errors: 7 ignored, 7 total + MSG + assert(result.status) + end + + def test_ignore_errors_partial + @project.write!("sorbet/ignored_errors.cfg", <<~CFG) + 5002:errors/errors.rb:5 + CFG + result = @project.spoom("srb tc --no-color") + assert_equal(<<~MSG, result.err) + 7003 - errors/errors.rb:5: Method `params` does not exist on `T.class_of(Foo)` + 7003 - errors/errors.rb:5: Method `sig` does not exist on `T.class_of(Foo)` + 7004 - errors/errors.rb:10: Wrong number of arguments for constructor. Expected: `0`, got: `1` + 7003 - errors/errors.rb:11: Method `c` does not exist on `T.class_of()` + 7004 - errors/errors.rb:11: Too many arguments provided for method `Foo#foo`. Expected: `1`, got: `2` + Errors: 2 ignored, 7 total + MSG + refute(result.status) + end + + def test_ignore_errors_with_custom_path + @project.write!("custom_ignores.cfg", <<~CFG) + 5002:errors/errors.rb:5 + 7003:errors/errors.rb:5 + 7004:errors/errors.rb:10 + 7003:errors/errors.rb:11 + 7004:errors/errors.rb:11 + CFG + result = @project.spoom("srb tc --no-color --ignore-errors=custom_ignores.cfg") + assert_equal(<<~MSG, result.err) + Errors: 7 ignored, 7 total + MSG + assert(result.status) + end + + def test_ignore_errors_with_comments_and_blank_lines + @project.write!("sorbet/ignored_errors.cfg", <<~CFG) + # This is a comment + + 5002:errors/errors.rb:5 + + # Another comment + CFG + result = @project.spoom("srb tc --no-color") + assert_equal(<<~MSG, result.err) + 7003 - errors/errors.rb:5: Method `params` does not exist on `T.class_of(Foo)` + 7003 - errors/errors.rb:5: Method `sig` does not exist on `T.class_of(Foo)` + 7004 - errors/errors.rb:10: Wrong number of arguments for constructor. Expected: `0`, got: `1` + 7003 - errors/errors.rb:11: Method `c` does not exist on `T.class_of()` + 7004 - errors/errors.rb:11: Too many arguments provided for method `Foo#foo`. Expected: `1`, got: `2` + Errors: 2 ignored, 7 total + MSG + refute(result.status) + end + + def test_ignore_errors_stale_entry + @project.write!("sorbet/ignored_errors.cfg", <<~CFG) + 5002:errors/errors.rb:5 + 9999:nonexistent.rb:1 + CFG + result = @project.spoom("srb tc --no-color") + assert_includes(result.err, "Stale entry in ignore file: 9999:nonexistent.rb:1") + refute(result.status) + end + def test_deprecated_command_spoom_tc @project.remove!("errors") result = @project.spoom("tc --no-color")