From 9152c2adb9379c3789515f232bd801cd9c51f173 Mon Sep 17 00:00:00 2001 From: Kevin Robell Date: Wed, 5 Nov 2025 16:02:11 -0800 Subject: [PATCH 1/3] Add a new cop `RSpec/Output` This is based on the `Rails/Output` cop with three minor changes. 1. Autocorrection is removed as the expectation is that the print statement will be removed by the user. 2. The message is changed. 3. The cop runs only on spec files. --- .rubocop.yml | 1 + CHANGELOG.md | 2 + config/default.yml | 6 + docs/modules/ROOT/pages/cops.adoc | 1 + docs/modules/ROOT/pages/cops_rspec.adoc | 31 +++++ lib/rubocop/cop/rspec/output.rb | 66 +++++++++ lib/rubocop/cop/rspec_cops.rb | 1 + spec/rubocop/cop/rspec/output_spec.rb | 169 ++++++++++++++++++++++++ 8 files changed, 277 insertions(+) create mode 100644 lib/rubocop/cop/rspec/output.rb create mode 100644 spec/rubocop/cop/rspec/output_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 4362ff878..59c5d81f4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -294,3 +294,4 @@ Performance/ZipWithoutBlock: {Enabled: true} RSpec/IncludeExamples: {Enabled: true} RSpec/LeakyLocalVariable: {Enabled: true} +RSpec/Output: {Enabled: true} diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d48b8a2..64db1b157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Fix detection of nameless doubles with methods in `RSpec/VerifiedDoubles`. ([@ushi-as]) - Improve an offense message for `RSpec/RepeatedExample` cop. ([@ydah]) - Let `RSpec/SpecFilePathFormat` leverage ActiveSupport inflections when configured. ([@corsonknowles], [@bquorning]) +- Add new cop `RSpec/Output`. ([@kevinrobell-st]) ## 3.7.0 (2025-09-01) @@ -1021,6 +1022,7 @@ Compatibility release so users can upgrade RuboCop to 0.51.0. No new features. [@jtannas]: https://github.com/jtannas [@k-s-a]: https://github.com/K-S-A [@kellysutton]: https://github.com/kellysutton +[@kevinrobell-st]: https://github.com/kevinrobell-st [@koic]: https://github.com/koic [@krororo]: https://github.com/krororo [@kuahyeow]: https://github.com/kuahyeow diff --git a/config/default.yml b/config/default.yml index 3c1e18410..d2fb43b44 100644 --- a/config/default.yml +++ b/config/default.yml @@ -758,6 +758,12 @@ RSpec/NotToNot: VersionAdded: '1.4' Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/NotToNot +RSpec/Output: + Description: Checks for the use of output calls like puts and print in specs. + Enabled: pending + VersionAdded: "<>" + Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Output + RSpec/OverwritingSetup: Description: Checks if there is a let/subject that overwrites an existing one. Enabled: true diff --git a/docs/modules/ROOT/pages/cops.adoc b/docs/modules/ROOT/pages/cops.adoc index fb43db294..9c1e7746f 100644 --- a/docs/modules/ROOT/pages/cops.adoc +++ b/docs/modules/ROOT/pages/cops.adoc @@ -77,6 +77,7 @@ * xref:cops_rspec.adoc#rspecnestedgroups[RSpec/NestedGroups] * xref:cops_rspec.adoc#rspecnoexpectationexample[RSpec/NoExpectationExample] * xref:cops_rspec.adoc#rspecnottonot[RSpec/NotToNot] +* xref:cops_rspec.adoc#rspecoutput[RSpec/Output] * xref:cops_rspec.adoc#rspecoverwritingsetup[RSpec/OverwritingSetup] * xref:cops_rspec.adoc#rspecpending[RSpec/Pending] * xref:cops_rspec.adoc#rspecpendingwithoutreason[RSpec/PendingWithoutReason] diff --git a/docs/modules/ROOT/pages/cops_rspec.adoc b/docs/modules/ROOT/pages/cops_rspec.adoc index 036569f0f..5fbaacfea 100644 --- a/docs/modules/ROOT/pages/cops_rspec.adoc +++ b/docs/modules/ROOT/pages/cops_rspec.adoc @@ -4718,6 +4718,37 @@ end * https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/NotToNot +[#rspecoutput] +== RSpec/Output + +|=== +| Enabled by default | Safe | Supports autocorrection | Version Added | Version Changed + +| Pending +| Yes +| No +| <> +| - +|=== + +Checks for the use of output calls like puts and print in specs. + +[#examples-rspecoutput] +=== Examples + +[source,ruby] +---- +# bad +puts 'A debug message' +pp 'A debug message' +print 'A debug message' +---- + +[#references-rspecoutput] +=== References + +* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Output + [#rspecoverwritingsetup] == RSpec/OverwritingSetup diff --git a/lib/rubocop/cop/rspec/output.rb b/lib/rubocop/cop/rspec/output.rb new file mode 100644 index 000000000..8404d11b7 --- /dev/null +++ b/lib/rubocop/cop/rspec/output.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + # NOTE: This is the same as the `Rails/Output` cop with minor changes. + module RSpec + # Checks for the use of output calls like puts and print in specs. + # + # @example + # # bad + # puts 'A debug message' + # pp 'A debug message' + # print 'A debug message' + class Output < Base + include RangeHelp + + MSG = 'Do not write to stdout in specs.' + RESTRICT_ON_SEND = %i[ap p pp pretty_print print puts binwrite syswrite + write write_nonblock].freeze + + # @!method output?(node) + def_node_matcher :output?, <<~PATTERN + (send nil? {:ap :p :pp :pretty_print :print :puts} ...) + PATTERN + + # @!method io_output?(node) + def_node_matcher :io_output?, <<~PATTERN + (send + { + (gvar #match_gvar?) + (const {nil? cbase} {:STDOUT :STDERR}) + } + {:binwrite :syswrite :write :write_nonblock} + ...) + PATTERN + + # rubocop:disable Metrics/CyclomaticComplexity + def on_send(node) + return if node.parent&.call_type? || node.block_node + return if !output?(node) && !io_output?(node) + return if node.arguments.any? { |arg| arg.type?(:hash, :block_pass) } + + range = offense_range(node) + + add_offense(range) + end + # rubocop:enable Metrics/CyclomaticComplexity + + private + + def match_gvar?(sym) + %i[$stdout $stderr].include?(sym) + end + + def offense_range(node) + if node.receiver + range_between(node.source_range.begin_pos, + node.loc.selector.end_pos) + else + node.loc.selector + end + end + end + end + end +end diff --git a/lib/rubocop/cop/rspec_cops.rb b/lib/rubocop/cop/rspec_cops.rb index d64ca9e51..877d466f2 100644 --- a/lib/rubocop/cop/rspec_cops.rb +++ b/lib/rubocop/cop/rspec_cops.rb @@ -75,6 +75,7 @@ require_relative 'rspec/nested_groups' require_relative 'rspec/no_expectation_example' require_relative 'rspec/not_to_not' +require_relative 'rspec/output' require_relative 'rspec/overwriting_setup' require_relative 'rspec/pending' require_relative 'rspec/pending_without_reason' diff --git a/spec/rubocop/cop/rspec/output_spec.rb b/spec/rubocop/cop/rspec/output_spec.rb new file mode 100644 index 000000000..3d5faddb0 --- /dev/null +++ b/spec/rubocop/cop/rspec/output_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::RSpec::Output do + it 'registers an offense for using `p` method without a receiver' do + expect_offense(<<~RUBY) + p "edmond dantes" + ^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense for using `puts` method without a receiver' do + expect_offense(<<~RUBY) + puts "sinbad" + ^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense for using `print` method without a receiver' do + expect_offense(<<~RUBY) + print "abbe busoni" + ^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense for using `pp` method without a receiver' do + expect_offense(<<~RUBY) + pp "monte cristo" + ^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense with `$stdout.write`' do + expect_offense(<<~RUBY) + $stdout.write "lord wilmore" + ^^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense with `$stderr.syswrite`' do + expect_offense(<<~RUBY) + $stderr.syswrite "faria" + ^^^^^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense with `STDOUT.write`' do + expect_offense(<<~RUBY) + STDOUT.write "bertuccio" + ^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense with `::STDOUT.write`' do + expect_offense(<<~RUBY) + ::STDOUT.write "bertuccio" + ^^^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense with `STDERR.write`' do + expect_offense(<<~RUBY) + STDERR.write "bertuccio" + ^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense with `::STDERR.write`' do + expect_offense(<<~RUBY) + ::STDERR.write "bertuccio" + ^^^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'does not record an offense for methods with a receiver' do + expect_no_offenses(<<~RUBY) + obj.print + something.p + nothing.pp + RUBY + end + + it 'registers an offense for methods without arguments' do + expect_offense(<<~RUBY) + print + ^^^^^ Do not write to stdout in specs. + pp + ^^ Do not write to stdout in specs. + puts + ^^^^ Do not write to stdout in specs. + $stdout.write + ^^^^^^^^^^^^^ Do not write to stdout in specs. + STDERR.write + ^^^^^^^^^^^^ Do not write to stdout in specs. + RUBY + end + + it 'registers an offense when `p` method with positional argument' do + expect_offense(<<~RUBY) + p(do_something) + ^ Do not write to stdout in specs. + RUBY + end + + it 'does not register an offense when a method is called ' \ + 'to a local variable with the same name as a print method' do + expect_no_offenses(<<~RUBY) + p.do_something + RUBY + end + + it 'does not register an offense when `p` method with keyword argument' do + expect_no_offenses(<<~RUBY) + p(class: 'this `p` method is a DSL') + RUBY + end + + it 'does not register an offense when `p` method with symbol proc' do + expect_no_offenses(<<~RUBY) + p(&:this_p_method_is_a_dsl) + RUBY + end + + it 'does not register an offense when the `p` method is called ' \ + 'with block argument' do + expect_no_offenses(<<~RUBY) + # phlex-rails gem. + div do + p { 'Some text' } + end + RUBY + end + + it 'does not register an offense when io method is called ' \ + 'with block argument' do + expect_no_offenses(<<~RUBY) + obj.write { do_somethig } + RUBY + end + + it 'does not register an offense when io method is called ' \ + 'with numbered block argument' do + expect_no_offenses(<<~RUBY) + obj.write { do_something(_1) } + RUBY + end + + it 'does not register an offense when io method is called ' \ + 'with `it` parameter', :ruby34, unsupported_on: :parser do + expect_no_offenses(<<~RUBY) + obj.write { do_something(it) } + RUBY + end + + it 'does not register an offense when a method is safe navigation called ' \ + 'to a local variable with the same name as a print method' do + expect_no_offenses(<<~RUBY) + p&.do_something + RUBY + end + + it 'does not record an offense for comments' do + expect_no_offenses(<<~RUBY) + # print "test" + # p + # $stdout.write + # STDERR.binwrite + RUBY + end +end From 17724ce34c5a2b60408a468a906423b173cb50b5 Mon Sep 17 00:00:00 2001 From: Kevin Robell Date: Wed, 12 Nov 2025 10:12:05 -0800 Subject: [PATCH 2/3] Clean up rubocop:disable Co-authored-by: Yudai Takada --- lib/rubocop/cop/rspec/output.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/rubocop/cop/rspec/output.rb b/lib/rubocop/cop/rspec/output.rb index 8404d11b7..e1546501f 100644 --- a/lib/rubocop/cop/rspec/output.rb +++ b/lib/rubocop/cop/rspec/output.rb @@ -34,8 +34,7 @@ class Output < Base ...) PATTERN - # rubocop:disable Metrics/CyclomaticComplexity - def on_send(node) + def on_send(node) # rubocop:disable Metrics/CyclomaticComplexity return if node.parent&.call_type? || node.block_node return if !output?(node) && !io_output?(node) return if node.arguments.any? { |arg| arg.type?(:hash, :block_pass) } @@ -44,7 +43,6 @@ def on_send(node) add_offense(range) end - # rubocop:enable Metrics/CyclomaticComplexity private From f4466c91798d51416e889ab2e9024d54ab8dc3a8 Mon Sep 17 00:00:00 2001 From: Kevin Robell Date: Mon, 17 Nov 2025 08:10:36 -0800 Subject: [PATCH 3/3] Update comment --- lib/rubocop/cop/rspec/output.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubocop/cop/rspec/output.rb b/lib/rubocop/cop/rspec/output.rb index e1546501f..d58c9e89a 100644 --- a/lib/rubocop/cop/rspec/output.rb +++ b/lib/rubocop/cop/rspec/output.rb @@ -2,7 +2,7 @@ module RuboCop module Cop - # NOTE: This is the same as the `Rails/Output` cop with minor changes. + # NOTE: Originally based on the `Rails/Output` cop. module RSpec # Checks for the use of output calls like puts and print in specs. #