From 11974b29114714fbb0de3160ae89fb561b70bd07 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Fri, 7 Aug 2026 11:58:22 -0400 Subject: [PATCH 1/6] Better plugin description --- lib/rubocop/cop/type_toolkit/plugin.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubocop/cop/type_toolkit/plugin.rb b/lib/rubocop/cop/type_toolkit/plugin.rb index a46c367..7bdcd89 100644 --- a/lib/rubocop/cop/type_toolkit/plugin.rb +++ b/lib/rubocop/cop/type_toolkit/plugin.rb @@ -13,7 +13,7 @@ def about name: "rubocop-type_toolkit", version: ::TypeToolkit::VERSION, homepage: "https://github.com/Shopify/type_toolkit", - description: "Detects misuse of UnexpectedNilError.", + description: "RuboCop rules for Type Toolkit.", ) end From 160a2894546e7127b7b81a9f5d05ecbf8799f77c Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Fri, 7 Aug 2026 13:38:18 -0400 Subject: [PATCH 2/6] Add PreferNotNil RuboCop cop --- config/default.yml | 5 + lib/rubocop-type_toolkit.rb | 1 + .../cop/type_toolkit/prefer_not_nil.rb | 90 +++++++++ .../cop/type_toolkit/prefer_not_nil_spec.rb | 191 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 lib/rubocop/cop/type_toolkit/prefer_not_nil.rb create mode 100644 spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb diff --git a/config/default.yml b/config/default.yml index 63fdd04..22bd7c3 100644 --- a/config/default.yml +++ b/config/default.yml @@ -2,3 +2,8 @@ TypeToolkit/DontExpectUnexpectedNil: Description: "Detects misuse of UnexpectedNilError (rescuing, raising, or asserting it)." Enabled: true VersionAdded: "0.1.0" + +TypeToolkit/PreferNotNil: + Description: "Replaces T.must assertions with Type Toolkit's not_nil! assertion." + Enabled: true + VersionAdded: "0.1.0" diff --git a/lib/rubocop-type_toolkit.rb b/lib/rubocop-type_toolkit.rb index d55e476..e716759 100644 --- a/lib/rubocop-type_toolkit.rb +++ b/lib/rubocop-type_toolkit.rb @@ -3,3 +3,4 @@ require "rubocop" require_relative "rubocop/cop/type_toolkit/plugin" require_relative "rubocop/cop/type_toolkit/dont_expect_unexpected_nil" +require_relative "rubocop/cop/type_toolkit/prefer_not_nil" diff --git a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb new file mode 100644 index 0000000..03be773 --- /dev/null +++ b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb @@ -0,0 +1,90 @@ +# typed: true +# frozen_string_literal: true + +module RuboCop + module Cop + module TypeToolkit + # Replaces Sorbet's `T.must(value)` assertion with Type Toolkit's `value.not_nil!` assertion. + class PreferNotNil < Base + extend AutoCorrector + + MSG = "Use `.not_nil!` instead of `T.must()`." + RESTRICT_ON_SEND = [:must].freeze + + KEYWORD_EXPRESSION_TYPES = [:defined?, :super, :yield, :zsuper].freeze + private_constant :KEYWORD_EXPRESSION_TYPES + + #: (RuboCop::AST::SendNode) -> void + def on_send(node) + return unless (argument = t_must_argument(node)) + + replacement = replacement_for(argument) + correction = correction_for(node, replacement) + if nested_t_must?(node) + add_offense(node, message: MSG) + else + add_offense(node, message: MSG) do |corrector| + corrector.replace(node, correction) + end + end + end + + private + + #: (RuboCop::AST::SendNode) -> RuboCop::AST::Node? + def t_must_argument(node) + receiver = node.receiver + return unless receiver.is_a?(RuboCop::AST::ConstNode) + return unless receiver.short_name == :T && node.method?(:must) && node.arguments.one? + + namespace = receiver.namespace + return unless namespace.nil? || namespace.cbase_type? + + argument = node.first_argument + return unless argument + return if argument.splat_type? || argument.kwsplat_type? + + argument + end + + #: (RuboCop::AST::Node) -> String + def replacement_for(argument) + source = argument.source + source = "(#{source})" if requires_parentheses?(argument) + "#{source}.not_nil!" + end + + #: (RuboCop::AST::SendNode, String) -> String + def correction_for(node, replacement) + return replacement unless node.multiline? + + grouped_source = node.source.sub(/\A(?:::)?T\.must/, "") + grouped_source = grouped_source.sub(/,(\s*\))\z/, '\1') + "#{grouped_source}.not_nil!" + end + + #: (RuboCop::AST::SendNode) -> bool + def nested_t_must?(node) + node.each_ancestor(:send).any? do |ancestor| + ancestor.is_a?(RuboCop::AST::SendNode) && t_must_argument(ancestor) + end + end + + #: (RuboCop::AST::Node) -> bool + def requires_parentheses?(argument) + return false if argument.begin_type? + + if argument.is_a?(RuboCop::AST::SendNode) + return true if argument.operator_method? + return true if argument.arguments? && !argument.parenthesized_call? + end + return true if argument.range_type? || argument.operator_keyword? + return true if argument.if_type? || argument.assignment? + return true if argument.any_block_type? + + KEYWORD_EXPRESSION_TYPES.include?(argument.type) + end + end + end + end +end diff --git a/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb b/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb new file mode 100644 index 0000000..2969c5d --- /dev/null +++ b/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb @@ -0,0 +1,191 @@ +# 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 + class PreferNotNilSpec < ::Minitest::Spec + include RuboCop::Minitest::AssertOffense + + MSG = "TypeToolkit/PreferNotNil: Use `.not_nil!` instead of `T.must()`." + + before do + @cop = PreferNotNil.new + end + + it "autocorrects T.must" do + assert_offense(<<~RUBY) + value = T.must(foo) + ^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + value = foo.not_nil! + RUBY + end + + it "autocorrects ::T.must" do + assert_offense(<<~RUBY) + value = ::T.must(foo) + ^^^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + value = foo.not_nil! + RUBY + end + + it "autocorrects inside string interpolation" do + assert_offense(<<~RUBY) + string = "\#{T.must(foo)}" + ^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + string = "\#{foo.not_nil!}" + RUBY + end + + it "preserves the precedence of conditional and range expressions" do + assert_offense(<<~RUBY) + conditional = T.must(condition ? foo : bar) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{MSG} + range = T.must(foo..bar) + ^^^^^^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + conditional = (condition ? foo : bar).not_nil! + range = (foo..bar).not_nil! + RUBY + end + + it "parenthesizes every expression that requires it" do + assert_offense(<<~RUBY) + operator = T.must(foo + bar) + ^^^^^^^^^^^^^^^^^ #{MSG} + logical = T.must(foo || bar) + ^^^^^^^^^^^^^^^^^^ #{MSG} + grouped = T.must((foo || bar)) + ^^^^^^^^^^^^^^^^^^^^ #{MSG} + assignment = T.must(foo = bar) + ^^^^^^^^^^^^^^^^^ #{MSG} + block_value = T.must(foo { bar }) + ^^^^^^^^^^^^^^^^^^^ #{MSG} + defined_value = T.must(defined?(foo)) + ^^^^^^^^^^^^^^^^^^^^^ #{MSG} + def example + T.must(yield foo) + ^^^^^^^^^^^^^^^^^ #{MSG} + end + def implicit_super + T.must(super) + ^^^^^^^^^^^^^ #{MSG} + end + def explicit_super + T.must(super(foo)) + ^^^^^^^^^^^^^^^^^^ #{MSG} + end + RUBY + + assert_correction(<<~RUBY) + operator = (foo + bar).not_nil! + logical = (foo || bar).not_nil! + grouped = (foo || bar).not_nil! + assignment = (foo = bar).not_nil! + block_value = (foo { bar }).not_nil! + defined_value = (defined?(foo)).not_nil! + def example + (yield foo).not_nil! + end + def implicit_super + (super).not_nil! + end + def explicit_super + (super(foo)).not_nil! + end + RUBY + end + + it "preserves the precedence of command calls" do + assert_offense(<<~RUBY) + value = T.must(fetch value) + ^^^^^^^^^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + value = (fetch value).not_nil! + RUBY + end + + it "does not add unnecessary parentheses to parenthesized calls" do + assert_offense(<<~RUBY) + value = T.must(fetch(value)) + ^^^^^^^^^^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + value = fetch(value).not_nil! + RUBY + end + + it "preserves comments in multiline calls" do + assert_offense(<<~RUBY) + value = T.must( + ^^^^^^^ #{MSG} + # Proven non-nil by validation. + foo, + ) + RUBY + + assert_correction(<<~RUBY) + value = ( + # Proven non-nil by validation. + foo + ).not_nil! + RUBY + end + + it "autocorrects nested T.must calls" do + assert_offense(<<~RUBY) + value = T.must(T.must(foo).bar) + ^^^^^^^^^^^^^^^^^^^^^^^ #{MSG} + ^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + value = foo.not_nil!.bar.not_nil! + RUBY + end + + it "autocorrects three nested T.must calls" do + assert_offense(<<~RUBY) + value = T.must(T.must(T.must(foo).bar).baz) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{MSG} + ^^^^^^^^^^^^^^^^^^^^^^^ #{MSG} + ^^^^^^^^^^^ #{MSG} + RUBY + + assert_correction(<<~RUBY) + value = foo.not_nil!.bar.not_nil!.baz.not_nil! + RUBY + end + + it "ignores other receivers, methods, and argument counts" do + assert_no_offenses(<<~RUBY) + Other::T.must(foo) + object.must(foo) + T.let(foo, String) + T.must(foo, bar) + T.must(*values) + RUBY + end + end + end + end +end From 1a21cc12d21a641f14678453dd0b209a5402372f Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Fri, 7 Aug 2026 14:45:01 -0400 Subject: [PATCH 3/6] Fix multiline T.must autocorrection --- .../cop/type_toolkit/prefer_not_nil.rb | 12 +++--- .../cop/type_toolkit/prefer_not_nil_spec.rb | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb index 03be773..e2094ca 100644 --- a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +++ b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb @@ -19,7 +19,7 @@ def on_send(node) return unless (argument = t_must_argument(node)) replacement = replacement_for(argument) - correction = correction_for(node, replacement) + correction = correction_for(node, argument, replacement) if nested_t_must?(node) add_offense(node, message: MSG) else @@ -54,12 +54,14 @@ def replacement_for(argument) "#{source}.not_nil!" end - #: (RuboCop::AST::SendNode, String) -> String - def correction_for(node, replacement) + #: (RuboCop::AST::SendNode, RuboCop::AST::Node, String) -> String + def correction_for(node, argument, replacement) return replacement unless node.multiline? - grouped_source = node.source.sub(/\A(?:::)?T\.must/, "") - grouped_source = grouped_source.sub(/,(\s*\))\z/, '\1') + grouped_range = node.source_range.with(begin_pos: node.loc.begin.begin_pos, end_pos: node.loc.end.end_pos) + grouped_source = grouped_range.source + comma_offset = argument.source_range.end_pos - grouped_range.begin_pos + grouped_source.slice!(comma_offset) if grouped_source.getbyte(comma_offset) == 44 "#{grouped_source}.not_nil!" end diff --git a/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb b/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb index 2969c5d..eb1cfce 100644 --- a/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb +++ b/spec/rubocop/cop/type_toolkit/prefer_not_nil_spec.rb @@ -151,6 +151,44 @@ def explicit_super RUBY end + it "autocorrects multiline calls with whitespace before the method" do + assert_offense(<<~RUBY) + first = T .must( + ^^^^^^^^ #{MSG} + foo, + ) + second = T + ^ #{MSG} + .must( + bar, + ) + RUBY + + assert_correction(<<~RUBY) + first = ( + foo + ).not_nil! + second = ( + bar + ).not_nil! + RUBY + end + + it "removes a trailing comma before an inline comment" do + assert_offense(<<~RUBY) + value = T.must( + ^^^^^^^ #{MSG} + foo, # Proven non-nil. + ) + RUBY + + assert_correction(<<~RUBY) + value = ( + foo # Proven non-nil. + ).not_nil! + RUBY + end + it "autocorrects nested T.must calls" do assert_offense(<<~RUBY) value = T.must(T.must(foo).bar) From ca08b740018f370165a30b3300afb6bd98ec96e9 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Fri, 7 Aug 2026 14:51:00 -0400 Subject: [PATCH 4/6] Clarify T.must argument extraction --- lib/rubocop/cop/type_toolkit/prefer_not_nil.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb index e2094ca..2a1b78c 100644 --- a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +++ b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb @@ -16,7 +16,7 @@ class PreferNotNil < Base #: (RuboCop::AST::SendNode) -> void def on_send(node) - return unless (argument = t_must_argument(node)) + return unless (argument = extract_t_must_argument(node)) replacement = replacement_for(argument) correction = correction_for(node, argument, replacement) @@ -32,7 +32,7 @@ def on_send(node) private #: (RuboCop::AST::SendNode) -> RuboCop::AST::Node? - def t_must_argument(node) + def extract_t_must_argument(node) receiver = node.receiver return unless receiver.is_a?(RuboCop::AST::ConstNode) return unless receiver.short_name == :T && node.method?(:must) && node.arguments.one? @@ -68,7 +68,7 @@ def correction_for(node, argument, replacement) #: (RuboCop::AST::SendNode) -> bool def nested_t_must?(node) node.each_ancestor(:send).any? do |ancestor| - ancestor.is_a?(RuboCop::AST::SendNode) && t_must_argument(ancestor) + ancestor.is_a?(RuboCop::AST::SendNode) && extract_t_must_argument(ancestor) end end From 907b678bf75c8d5cfc296749de05a28d27e87952 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Fri, 7 Aug 2026 14:53:02 -0400 Subject: [PATCH 5/6] Avoid unused autocorrection work --- lib/rubocop/cop/type_toolkit/prefer_not_nil.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb index 2a1b78c..71be0b0 100644 --- a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +++ b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb @@ -18,11 +18,12 @@ class PreferNotNil < Base def on_send(node) return unless (argument = extract_t_must_argument(node)) - replacement = replacement_for(argument) - correction = correction_for(node, argument, replacement) if nested_t_must?(node) add_offense(node, message: MSG) else + replacement = replacement_for(argument) + correction = correction_for(node, argument, replacement) + add_offense(node, message: MSG) do |corrector| corrector.replace(node, correction) end From 4153813f63aebfaa20999a7f6fc283c233706ef7 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Fri, 7 Aug 2026 15:06:59 -0400 Subject: [PATCH 6/6] Name trailing comma byte --- lib/rubocop/cop/type_toolkit/prefer_not_nil.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb index 71be0b0..ff3968b 100644 --- a/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb +++ b/lib/rubocop/cop/type_toolkit/prefer_not_nil.rb @@ -11,6 +11,9 @@ class PreferNotNil < Base MSG = "Use `.not_nil!` instead of `T.must()`." RESTRICT_ON_SEND = [:must].freeze + COMMA_BYTE = ",".ord + private_constant :COMMA_BYTE + KEYWORD_EXPRESSION_TYPES = [:defined?, :super, :yield, :zsuper].freeze private_constant :KEYWORD_EXPRESSION_TYPES @@ -62,7 +65,7 @@ def correction_for(node, argument, replacement) grouped_range = node.source_range.with(begin_pos: node.loc.begin.begin_pos, end_pos: node.loc.end.end_pos) grouped_source = grouped_range.source comma_offset = argument.source_range.end_pos - grouped_range.begin_pos - grouped_source.slice!(comma_offset) if grouped_source.getbyte(comma_offset) == 44 + grouped_source.slice!(comma_offset) if grouped_source.getbyte(comma_offset) == COMMA_BYTE "#{grouped_source}.not_nil!" end