From 79b1b3b9301edc040767d86de887ba41abfdba0a Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Thu, 19 Feb 2026 18:48:35 -0500 Subject: [PATCH 1/2] Add `not_nil!` assertion method --- lib/type_toolkit/all.rb | 0 lib/type_toolkit/ext/nil_assertions.rb | 34 ++++++++++++++++++++++++++ spec/nil_assertions.rb | 19 ++++++++++++++ spec/spec_helper.rb | 2 +- 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 lib/type_toolkit/all.rb create mode 100644 lib/type_toolkit/ext/nil_assertions.rb create mode 100644 spec/nil_assertions.rb diff --git a/lib/type_toolkit/all.rb b/lib/type_toolkit/all.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/type_toolkit/ext/nil_assertions.rb b/lib/type_toolkit/ext/nil_assertions.rb new file mode 100644 index 0000000..184ecf1 --- /dev/null +++ b/lib/type_toolkit/ext/nil_assertions.rb @@ -0,0 +1,34 @@ +# typed: true +# frozen_string_literal: true + +# Asserts that the receiver is not nil. +# +# You should use `not_nil!` in places where you're absolutely sure a `nil` value can't occur. +# This should be done as closely to where the value is created as possible, so that the `nil` +# value doesn't have a chance to be passed around the system. This way, failures occur close to +# the source of the problem, and are easier to fix. +module Kernel + #: -> self + def not_nil! + self + end +end + +class NilClass + # @override + #: -> bot + def not_nil! + raise UnexpectedNilError + end +end + +# An error raised when calling `#not_nil!` on a `nil` value. +# +# `UnexpectedNilError` should never occur in well-formed code, so it should never be rescued. +# This is why it inherits from `Exception` instead of `StandardError`, +# so that bare rescues clauses (like `rescue => e`) don't rescue it. +class UnexpectedNilError < Exception # rubocop:disable Lint/InheritException + def initialize(message = "Called `not_nil!` on nil.") + super(message) + end +end diff --git a/spec/nil_assertions.rb b/spec/nil_assertions.rb new file mode 100644 index 0000000..711ae82 --- /dev/null +++ b/spec/nil_assertions.rb @@ -0,0 +1,19 @@ +# typed: true +# frozen_string_literal: true + +require "spec_helper" + +module TypeToolkit + class NilAssertionsTest < Minitest::Spec + describe "#not_nil!" do + it "returns self on non-nil values" do + x = "Hello, world!" + assert_same x, x.not_nil! + end + + it "raises an error on nil values" do + assert_raises(UnexpectedNilError) { nil.not_nil! } + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 26f6041..3e509a1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) -require "type_toolkit" +require "type_toolkit/all" require "minitest/autorun" require "minitest/spec" From da8fd60b839f26950c49a262effb2ca16c1e7d63 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Thu, 19 Feb 2026 19:30:20 -0500 Subject: [PATCH 2/2] Add `TypeToolKit/DontExpectUnexepctedNil` cop --- .rubocop.yml | 5 + config/default.yml | 4 + lib/rubocop-type_toolkit.rb | 7 + .../dont_expect_unexpected_nil.rb | 103 ++++++++++ sorbet/rbi/shims/rubocop_minitest.rbi | 17 ++ .../dont_expect_unexpected_nil_spec.rb | 181 ++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 config/default.yml create mode 100644 lib/rubocop-type_toolkit.rb create mode 100644 lib/rubocop/cop/type_toolkit/dont_expect_unexpected_nil.rb create mode 100644 sorbet/rbi/shims/rubocop_minitest.rbi create mode 100644 spec/rubocop/cop/type_toolkit/dont_expect_unexpected_nil_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 4f5a2e8..76c00dd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,5 +9,10 @@ AllCops: TargetRubyVersion: 3.2 NewCops: enable +Naming/FileName: + Exclude: + # This file matches RuboCop naming conventions, like `rubocop-rails`, `rubocop-sorbet`, etc. + - lib/rubocop-type_toolkit.rb + Style/Semicolon: AllowAsExpressionSeparator: true diff --git a/config/default.yml b/config/default.yml new file mode 100644 index 0000000..63fdd04 --- /dev/null +++ b/config/default.yml @@ -0,0 +1,4 @@ +TypeToolkit/DontExpectUnexpectedNil: + Description: "Detects misuse of UnexpectedNilError (rescuing, raising, or asserting it)." + Enabled: true + VersionAdded: "0.1.0" diff --git a/lib/rubocop-type_toolkit.rb b/lib/rubocop-type_toolkit.rb new file mode 100644 index 0000000..8526e3e --- /dev/null +++ b/lib/rubocop-type_toolkit.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "rubocop" + +RuboCop::ConfigLoader.inject_defaults!(File.join(__dir__, "..", "config", "default.yml")) + +require_relative "rubocop/cop/type_toolkit/dont_expect_unexpected_nil" diff --git a/lib/rubocop/cop/type_toolkit/dont_expect_unexpected_nil.rb b/lib/rubocop/cop/type_toolkit/dont_expect_unexpected_nil.rb new file mode 100644 index 0000000..103b919 --- /dev/null +++ b/lib/rubocop/cop/type_toolkit/dont_expect_unexpected_nil.rb @@ -0,0 +1,103 @@ +# typed: true +# frozen_string_literal: true + +module RuboCop + module Cop + module TypeToolkit + # This cop detects attempts to raise, rescue, or otherwise use the `UnexpectedNilError` class. + class DontExpectUnexpectedNil < Base + RESTRICT_ON_SEND = [:assert_raises, :raise].freeze + + #: (RuboCop::AST::SendNode) -> void + def on_send(node) + case node.method_name + # when :raise then check_raise(node) + when :assert_raises then check_assert_raises(node) + end + end + + #: (RuboCop::AST::ResbodyNode) -> void + def on_resbody(node) + if (rescued_cls = node.exceptions.find { |ex_class| ex_class.const_type? && unexpected_nil_error?(ex_class) }) + message = "It is always a mistake for `not_nil!` to be called on nil, " \ + "so you should never try to rescue `UnexpectedNilError` specifically. " \ + "Change your code to gracefully handle `nil` instead." + add_offense(rescued_cls, message:) + ignore_const_node(rescued_cls) + end + end + + # This is a catch-all for cases where the `UnexpectedNilError` class is used outside of a raise, rescue, etc. + #: (RuboCop::AST::ConstNode) -> void + def on_const(node) + # Don't report this node right away, in case its parent AST is reported by one of the other cases. + # Instead, record it for now, and maybe report it at the end of the investigation. + if unexpected_nil_error?(node) + @const_read_nodes ||= Set.new.compare_by_identity + @const_read_nodes << node + end + end + + # @override + #: -> void + def on_investigation_end + @const_read_nodes&.each do |node| + next if @ignored_const_nodes&.include?(node) + + message = "`UnexpectedNilError` should only ever be used by `#not_nil!`." + add_offense(node, message:) + end + + super + end + + private + + #: (RuboCop::AST::ConstNode) -> bool + def unexpected_nil_error?(node) + node.short_name == :UnexpectedNilError && (node.namespace.nil? || node.namespace.cbase_type?) + end + + # Check for `raise UnexpectedNilError` + #: (RuboCop::AST::SendNode) -> void + def check_raise(node) + constant = case (first_arg = node.arguments.first) + when RuboCop::AST::ConstNode + node.arguments.first + when RuboCop::AST::SendNode + return unless first_arg.method_name == :new && first_arg.receiver.const_type? + + first_arg.receiver + else return + end + + if unexpected_nil_error?(constant) + message = "`UnexpectedNilError` should only ever be raised by `NilClass#not_nil!`." + add_offense(node, message:) + ignore_const_node(constant) + end + end + + # Check for `assert_raises UnexpectedNilError` + #: (RuboCop::AST::SendNode) -> void + def check_assert_raises(node) + if (constants = node.arguments.filter { |arg| arg.const_type? && unexpected_nil_error?(arg) }).any? + message = "It is always a mistake for `not_nil!` to be called on nil, " \ + "so tests should not expect any code to raise `UnexpectedNilError`. " \ + "Change your code to gracefully handle `nil` instead." + add_offense(node, message:) + ignore_const_node(*constants) + end + end + + # Call this when this constant node is part of a node tree that already has an offense. + # This way we don't report a second offense for the same constant. + #: (*RuboCop::AST::ConstNode) -> void + def ignore_const_node(*nodes) + @ignored_const_nodes ||= Set.new.compare_by_identity + @ignored_const_nodes.merge(nodes) + end + end + end + end +end diff --git a/sorbet/rbi/shims/rubocop_minitest.rbi b/sorbet/rbi/shims/rubocop_minitest.rbi new file mode 100644 index 0000000..18d044a --- /dev/null +++ b/sorbet/rbi/shims/rubocop_minitest.rbi @@ -0,0 +1,17 @@ +# typed: true +# frozen_string_literal: true + +module RuboCop + module Minitest + module AssertOffense + sig { params(source: String).void } + def assert_offense(source); end + + sig { params(source: String).void } + def assert_no_offenses(source); end + + sig { params(source: String).void } + def assert_correction(source); end + end + end +end diff --git a/spec/rubocop/cop/type_toolkit/dont_expect_unexpected_nil_spec.rb b/spec/rubocop/cop/type_toolkit/dont_expect_unexpected_nil_spec.rb new file mode 100644 index 0000000..9177c98 --- /dev/null +++ b/spec/rubocop/cop/type_toolkit/dont_expect_unexpected_nil_spec.rb @@ -0,0 +1,181 @@ +# typed: true +# frozen_string_literal: true + +require "spec_helper" +require "rubocop" +require "rubocop/minitest/assert_offense" +require "rubocop-type_toolkit" + +module RuboCop + module Cop + module TypeToolkit + describe DontExpectUnexpectedNil do + include RuboCop::Minitest::AssertOffense + + before do + @cop = DontExpectUnexpectedNil.new + end + + describe "assert_raises with UnexpectedNilError" do + it "adds offense when assert_raises is used with UnexpectedNilError" do + assert_offense(<<~RUBY) + assert_raises(UnexpectedNilError) { foo } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} + RUBY + end + + it "adds offense when assert_raises is used with ::UnexpectedNilError" do + assert_offense(<<~RUBY) + assert_raises(::UnexpectedNilError) { foo } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} + RUBY + end + + it "adds offense when assert_raises is used with do...end block" do + assert_offense(<<~RUBY) + assert_raises(UnexpectedNilError) do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} + foo + end + RUBY + end + + it "adds offense when UnexpectedNilError is among other arguments" do + assert_offense(<<~RUBY) + assert_raises(ArgumentError, UnexpectedNilError) { foo } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} + RUBY + end + + it "does not add offense when assert_raises uses a different error" do + assert_no_offenses(<<~RUBY) + assert_raises(ArgumentError) { foo } + RUBY + end + end + + describe "rescuing UnexpectedNilError" do + it "adds offense when rescuing UnexpectedNilError" do + assert_offense(<<~RUBY) + begin + foo + rescue UnexpectedNilError + ^^^^^^^^^^^^^^^^^^ #{rescue_message} + bar + end + RUBY + end + + it "adds offense when rescuing ::UnexpectedNilError" do + assert_offense(<<~RUBY) + begin + foo + rescue ::UnexpectedNilError + ^^^^^^^^^^^^^^^^^^^^ #{rescue_message} + bar + end + RUBY + end + + it "adds offense when rescuing UnexpectedNilError among other exceptions" do + assert_offense(<<~RUBY) + begin + foo + rescue UnexpectedNilError, ArgumentError + ^^^^^^^^^^^^^^^^^^ #{rescue_message} + bar + end + RUBY + end + + it "does not add offense when rescuing other exceptions" do + assert_no_offenses(<<~RUBY) + begin + foo + rescue ArgumentError + bar + end + RUBY + end + end + + describe "raising UnexpectedNilError" do + it "adds offense when raising UnexpectedNilError" do + assert_offense(<<~RUBY) + raise UnexpectedNilError + ^^^^^^^^^^^^^^^^^^ #{general_usage_message} + RUBY + end + + it "adds offense when raising UnexpectedNilError with a message" do + assert_offense(<<~RUBY) + raise UnexpectedNilError, "message" + ^^^^^^^^^^^^^^^^^^ #{general_usage_message} + RUBY + end + + it "adds offense when raising UnexpectedNilError.new" do + assert_offense(<<~RUBY) + raise UnexpectedNilError.new + ^^^^^^^^^^^^^^^^^^ #{general_usage_message} + RUBY + end + + it "adds offense when raising ::UnexpectedNilError" do + assert_offense(<<~RUBY) + raise ::UnexpectedNilError + ^^^^^^^^^^^^^^^^^^^^ #{general_usage_message} + RUBY + end + + it "does not add offense when raising other exceptions" do + assert_no_offenses(<<~RUBY) + raise ArgumentError + RUBY + end + end + + describe "other usages of UnexpectedNilError" do + it "adds offense when using UnexpectedNilError" do + assert_offense(<<~RUBY) + x = UnexpectedNilError + ^^^^^^^^^^^^^^^^^^ #{general_usage_message} + RUBY + end + + it "adds offense when using ::UnexpectedNilError" do + assert_offense(<<~RUBY) + x = ::UnexpectedNilError + ^^^^^^^^^^^^^^^^^^^^ #{general_usage_message} + RUBY + end + + it "does not add offense when using other constants" do + assert_no_offenses(<<~RUBY) + x = ArgumentError + x = ::ArgumentError + RUBY + end + end + + private + + def assert_raises_message + "TypeToolkit/DontExpectUnexpectedNil: It is always a mistake for `not_nil!` to be called on nil, " \ + "so tests should not expect any code to raise `UnexpectedNilError`. " \ + "Change your code to gracefully handle `nil` instead." + end + + def rescue_message + "TypeToolkit/DontExpectUnexpectedNil: It is always a mistake for `not_nil!` to be called on nil, " \ + "so you should never try to rescue `UnexpectedNilError` specifically. " \ + "Change your code to gracefully handle `nil` instead." + end + + def general_usage_message + "TypeToolkit/DontExpectUnexpectedNil: `UnexpectedNilError` should only ever be used by `#not_nil!`." + end + end + end + end +end