rubocop-sane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 46577dac32dde6106d874ce5fa85e7a54a94d589e075b54d281d7b68d9649e19
4
+ data.tar.gz: 8e1280ecc3bee57ed3cb65924ee86ce9b02c6032e74ee58864989fdf9b4cdac9
5
+ SHA512:
6
+ metadata.gz: 70e429be341dc7b52167483547162dcf4f52a99f278c98782eec989d0a8a061b72f40cd8dfd5d8b9d56cdfd79b9eb3c1934c353e1de29b435f46790b9c1586eb
7
+ data.tar.gz: 58dc631f79eb2cb9a022359fe0ba690ae55607b927b840113f469f648909f205619d5ef15fd427d88a24ed1ddac43fd7156b0aa7c1576385585951b5228706ea
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,110 @@
1
+ plugins:
2
+ - rubocop-rake
3
+ - rubocop-rspec
4
+ - rubocop-performance
5
+
6
+ AllCops:
7
+ DisplayCopNames: true
8
+ DisplayStyleGuide: true
9
+ NewCops: enable
10
+ TargetRubyVersion: 3.2
11
+ Exclude:
12
+ - sorbet/**/*.rbi
13
+
14
+ # Standard naming for RuboCop extension gems
15
+ Naming/FileName:
16
+ Exclude:
17
+ - lib/rubocop-sane.rb
18
+
19
+ Layout/FirstArrayElementIndentation:
20
+ EnforcedStyle: consistent
21
+
22
+ Layout/FirstHashElementIndentation:
23
+ EnforcedStyle: consistent
24
+
25
+ Layout/MultilineMethodCallIndentation:
26
+ EnforcedStyle: indented
27
+
28
+ Layout/SpaceInsideArrayLiteralBrackets:
29
+ EnforcedStyle: no_space
30
+
31
+ Lint/EmptyFile:
32
+ Enabled: true
33
+
34
+ Metrics/AbcSize:
35
+ Max: 20
36
+ Exclude:
37
+ - lib/rubocop/cop/sane/empty_lines_around_multiline_block.rb
38
+
39
+ Metrics/BlockLength:
40
+ Exclude:
41
+ - spec/**/*_spec.rb
42
+
43
+ Metrics/ClassLength:
44
+ Max: 150
45
+ Exclude:
46
+ - lib/rubocop/cop/sane/empty_lines_around_multiline_block.rb
47
+
48
+ Metrics/CyclomaticComplexity:
49
+ Max: 10
50
+ Exclude:
51
+ - lib/rubocop/cop/sane/empty_lines_around_multiline_block.rb
52
+
53
+ Metrics/MethodLength:
54
+ Max: 20
55
+
56
+ Metrics/PerceivedComplexity:
57
+ Max: 10
58
+ Exclude:
59
+ - lib/rubocop/cop/sane/empty_lines_around_multiline_block.rb
60
+
61
+ Naming/PredicatePrefix:
62
+ Enabled: false
63
+
64
+ RSpec/ExampleLength:
65
+ Max: 20
66
+
67
+ RSpec/MultipleDescribes:
68
+ Enabled: false
69
+
70
+ RSpec/MultipleExpectations:
71
+ Max: 15
72
+
73
+ RSpec/MultipleMemoizedHelpers:
74
+ Max: 10
75
+
76
+ RSpec/NestedGroups:
77
+ Max: 5
78
+
79
+ Style/ClassVars:
80
+ Enabled: false
81
+
82
+ Style/ConditionalAssignment:
83
+ Enabled: false
84
+
85
+ Style/Documentation:
86
+ Enabled: false
87
+
88
+ Style/GuardClause:
89
+ Enabled: false
90
+
91
+ Style/StringLiterals:
92
+ EnforcedStyle: double_quotes
93
+
94
+ Style/SymbolArray:
95
+ EnforcedStyle: brackets
96
+
97
+ Style/TrailingCommaInArguments:
98
+ EnforcedStyleForMultiline: diff_comma
99
+
100
+ Style/TrailingCommaInArrayLiteral:
101
+ EnforcedStyleForMultiline: consistent_comma
102
+
103
+ Style/TrailingCommaInHashLiteral:
104
+ EnforcedStyleForMultiline: consistent_comma
105
+
106
+ Style/WordArray:
107
+ EnforcedStyle: brackets
108
+
109
+ Style/NumericPredicate:
110
+ Enabled: false
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
3
+ "version": "0.2",
4
+ "dictionaryDefinitions": [
5
+ {
6
+ "name": "project-words",
7
+ "path": "./project-words.txt",
8
+ "addWords": true
9
+ }
10
+ ],
11
+ "dictionaries": [
12
+ "project-words"
13
+ ],
14
+ "ignorePaths": [
15
+ ".idea",
16
+ ".ruby-lsp",
17
+ "log",
18
+ "storage",
19
+ "tmp",
20
+ "vendor",
21
+ "*.svg",
22
+ "*.dump",
23
+ "*.enc",
24
+ "Gemfile.lock",
25
+ "/.vscode/project-words.txt",
26
+ "spec/fixtures/**/*"
27
+ ]
28
+ }
@@ -0,0 +1,6 @@
1
+ bindir
2
+ Kodkod
3
+ numblock
4
+ popen
5
+ readlines
6
+ RuboCop
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-12-19
4
+
5
+ - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1,77 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ rubocop-sane is a RuboCop extension gem that provides custom cops for enforcing sensible Ruby coding conventions. It uses the LintRoller plugin system for integration with RuboCop.
8
+
9
+ ## Common Commands
10
+
11
+ ```bash
12
+ # Install dependencies
13
+ bin/setup
14
+
15
+ # Run all tests
16
+ bundle exec rake spec
17
+
18
+ # Run a single test file
19
+ bundle exec rspec spec/rubocop/cop/sane/disallow_methods_spec.rb
20
+
21
+ # Run a single test by line number
22
+ bundle exec rspec spec/rubocop/cop/sane/disallow_methods_spec.rb:42
23
+
24
+ # Run RuboCop linting
25
+ bundle exec rubocop
26
+
27
+ # Run all checks (tests + rubocop)
28
+ bundle exec rake
29
+
30
+ # Interactive console
31
+ bin/console
32
+ ```
33
+
34
+ ## Architecture
35
+
36
+ ### Entry Point & Plugin System
37
+
38
+ - `lib/rubocop-sane.rb` - Main entry point that loads all components
39
+ - `lib/rubocop/sane/plugin.rb` - LintRoller plugin that integrates with RuboCop's plugin system
40
+ - `config/default.yml` - Default cop configurations
41
+
42
+ ### Cop Structure
43
+
44
+ All cops live under `lib/rubocop/cop/sane/` and follow RuboCop's cop pattern:
45
+ - Inherit from `RuboCop::Cop::Base`
46
+ - Use `extend AutoCorrector` for auto-fix support
47
+ - Implement `on_*` methods (e.g., `on_send`, `on_if`) to visit AST nodes
48
+
49
+ ### Current Cops
50
+
51
+ 1. **DisallowMethods** - Configurable cop for method replacements and prohibitions with auto-correct
52
+ 2. **ConditionalAssignmentAllowTernary** - Enforces assignment inside conditions but allows ternary operators
53
+ 3. **EmptyLineBeforeComment** - Requires blank line before comments (except after block starts, class/method definitions, etc.)
54
+ 4. **EmptyLinesAroundMultilineBlock** - Enforces empty lines around multiline if/case/block statements
55
+
56
+ ### Testing
57
+
58
+ Tests use RuboCop's `ExpectOffense` helper which provides a DSL for testing cop behavior:
59
+ ```ruby
60
+ expect_offense(<<~RUBY)
61
+ foo = if bar
62
+ ^^^^^^^^^^^^ Move the assignment inside the `if` branch.
63
+ 1
64
+ else
65
+ 2
66
+ end
67
+ RUBY
68
+ ```
69
+
70
+ The `^` characters mark where the offense should be detected.
71
+
72
+ ## Adding a New Cop
73
+
74
+ 1. Create cop file in `lib/rubocop/cop/sane/your_cop.rb`
75
+ 2. Add require in `lib/rubocop/cop/sane_cops.rb`
76
+ 3. Add configuration in `config/default.yml`
77
+ 4. Create spec in `spec/rubocop/cop/sane/your_cop_spec.rb`
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "rubocop-sane" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["678665+akodkod@users.noreply.github.com"](mailto:"678665+akodkod@users.noreply.github.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Andrew Kodkod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Rubocop::Sane
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rubocop/sane`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/rubocop-sane. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/rubocop-sane/blob/main/CODE_OF_CONDUCT.md).
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
+
41
+ ## Code of Conduct
42
+
43
+ Everyone interacting in the Rubocop::Sane project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/rubocop-sane/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: [:spec, :rubocop]
@@ -0,0 +1,33 @@
1
+ Sane:
2
+ Enabled: true
3
+ DocumentationBaseURL: https://github.com/akodkod/rubocop-sane
4
+
5
+ Sane/DisallowMethods:
6
+ Description: "Enforces method replacements and prohibitions."
7
+ Enabled: true
8
+ VersionAdded: "0.1.0"
9
+ SafeAutoCorrect: false
10
+ ReplaceMethods: {}
11
+ ProhibitedMethods: {}
12
+
13
+ Sane/ConditionalAssignmentAllowTernary:
14
+ Description: "Enforces assignment inside conditions, but allows ternary operators."
15
+ Enabled: true
16
+ VersionAdded: "0.1.0"
17
+
18
+ Sane/EmptyLineBeforeComment:
19
+ Description: "Enforces empty line before comments, except after block starts."
20
+ Enabled: true
21
+ VersionAdded: "0.1.0"
22
+ SafeAutoCorrect: true
23
+
24
+ Sane/EmptyLinesAroundMultilineBlock:
25
+ Description: "Enforces empty lines before and after multiline blocks."
26
+ Enabled: true
27
+ VersionAdded: "0.1.0"
28
+ SafeAutoCorrect: true
29
+
30
+ Sane/NoMethodCallAfterEnd:
31
+ Description: "Prohibits calling methods directly after `end`."
32
+ Enabled: true
33
+ VersionAdded: "0.1.0"
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # This cop enforces `assign_inside_condition` style like
7
+ # `Style/ConditionalAssignment`, but allows ternary operators.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # foo = if condition
12
+ # 1
13
+ # else
14
+ # 2
15
+ # end
16
+ #
17
+ # # bad
18
+ # foo = case bar
19
+ # when :a then 1
20
+ # when :b then 2
21
+ # end
22
+ #
23
+ # # good - assignment inside condition
24
+ # if condition
25
+ # foo = 1
26
+ # else
27
+ # foo = 2
28
+ # end
29
+ #
30
+ # # good - ternary operators are allowed
31
+ # foo = condition ? 1 : 2
32
+ #
33
+ # # good - multiline ternary operators are allowed
34
+ # foo = condition
35
+ # ? 1
36
+ # : 2
37
+ #
38
+ class ConditionalAssignmentAllowTernary < Base
39
+ MSG = "Move the assignment inside the `%<keyword>s` branch."
40
+
41
+ ASSIGNMENT_TYPES = [:lvasgn, :ivasgn, :cvasgn, :gvasgn, :casgn].freeze
42
+
43
+ def on_lvasgn(node)
44
+ check_assignment(node)
45
+ end
46
+
47
+ alias on_ivasgn on_lvasgn
48
+ alias on_cvasgn on_lvasgn
49
+ alias on_gvasgn on_lvasgn
50
+ alias on_casgn on_lvasgn
51
+
52
+ def on_masgn(node)
53
+ check_assignment(node)
54
+ end
55
+
56
+ def on_op_asgn(node)
57
+ check_assignment(node)
58
+ end
59
+
60
+ def on_or_asgn(node)
61
+ check_assignment(node)
62
+ end
63
+
64
+ def on_and_asgn(node)
65
+ check_assignment(node)
66
+ end
67
+
68
+ private
69
+
70
+ def check_assignment(node)
71
+ return unless assignment_to_conditional?(node)
72
+
73
+ rhs = extract_rhs(node)
74
+ return unless rhs
75
+
76
+ keyword = rhs.if_type? ? rhs.keyword : "case"
77
+ add_offense(node, message: format(MSG, keyword: keyword))
78
+ end
79
+
80
+ def assignment_to_conditional?(node)
81
+ rhs = extract_rhs(node)
82
+ return false unless rhs
83
+
84
+ if rhs.if_type?
85
+ # Allow ternary operators, only flag if/else blocks
86
+ return false if rhs.ternary?
87
+
88
+ rhs.else?
89
+ else
90
+ rhs.case_type?
91
+ end
92
+ end
93
+
94
+ def extract_rhs(node)
95
+ case node.type
96
+ when :lvasgn, :ivasgn, :cvasgn, :gvasgn, :masgn, :or_asgn, :and_asgn
97
+ node.children[1]
98
+ when :casgn, :op_asgn
99
+ node.children[2]
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Enforces method replacements and prohibitions.
7
+ #
8
+ # This cop checks for usage of specified methods and either suggests
9
+ # replacements (with auto-correction) or prohibits usage entirely.
10
+ #
11
+ # @example ReplaceMethods configuration
12
+ # # .rubocop.yml
13
+ # # Sane/DisallowMethods:
14
+ # # ReplaceMethods:
15
+ # # deliver_now:
16
+ # # with: deliver_later
17
+ # # reason: "`deliver_later` sends the email via background job"
18
+ #
19
+ # # bad
20
+ # UserMailer.welcome(user).deliver_now
21
+ #
22
+ # # good
23
+ # UserMailer.welcome(user).deliver_later
24
+ #
25
+ # @example ProhibitedMethods configuration
26
+ # # .rubocop.yml
27
+ # # Sane/DisallowMethods:
28
+ # # ProhibitedMethods:
29
+ # # dangerous_method:
30
+ # # reason: "This method is deprecated and unsafe"
31
+ #
32
+ # # bad
33
+ # obj.dangerous_method
34
+ #
35
+ class DisallowMethods < Base
36
+ extend AutoCorrector
37
+
38
+ REPLACEABLE_METHOD_MSG = "You should use `%<with>s` instead of `%<method_name>s` because %<reason>s"
39
+ PROHIBITED_METHOD_MSG = "You should not use `%<method_name>s` because %<reason>s"
40
+
41
+ def on_send(node)
42
+ method_name = node.method_name
43
+ replaceable = replace_methods[method_name]
44
+ prohibited = prohibited_methods[method_name]
45
+
46
+ if replaceable
47
+ handle_replaceable_method(node, method_name, replaceable)
48
+ elsif prohibited
49
+ handle_prohibited_method(node, method_name, prohibited)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def handle_replaceable_method(node, method_name, config)
56
+ message = format(
57
+ REPLACEABLE_METHOD_MSG,
58
+ method_name: method_name,
59
+ with: config["with"],
60
+ reason: config["reason"],
61
+ )
62
+
63
+ add_offense(node, severity: :error, message: message) do |corrector|
64
+ corrector.replace(node.loc.selector, config["with"])
65
+ end
66
+ end
67
+
68
+ def handle_prohibited_method(node, method_name, config)
69
+ message = format(
70
+ PROHIBITED_METHOD_MSG,
71
+ method_name: method_name,
72
+ reason: config["reason"],
73
+ )
74
+
75
+ add_offense(node, severity: :error, message: message)
76
+ end
77
+
78
+ def replace_methods
79
+ @replace_methods ||= build_method_hash(cop_config.fetch("ReplaceMethods", {}))
80
+ end
81
+
82
+ def prohibited_methods
83
+ @prohibited_methods ||= build_method_hash(cop_config.fetch("ProhibitedMethods", {}))
84
+ end
85
+
86
+ def build_method_hash(config)
87
+ return {} unless config.is_a?(Hash)
88
+
89
+ config.transform_keys(&:to_sym)
90
+ end
91
+
92
+ def safe_autocorrect?
93
+ false
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Enforces an empty line before comments, except when the previous line
7
+ # is the start of a block, class, method, or another comment.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # foo = 1
12
+ # # This is a comment
13
+ # bar = 2
14
+ #
15
+ # # good
16
+ # foo = 1
17
+ #
18
+ # # This is a comment
19
+ # bar = 2
20
+ #
21
+ # # good - after block/class/method start
22
+ # def foo
23
+ # # This comment doesn't need a blank line
24
+ # bar
25
+ # end
26
+ #
27
+ # # good - consecutive comments
28
+ # # First comment
29
+ # # Second comment
30
+ #
31
+ # # good - after control structure start
32
+ # if condition
33
+ # # Comment inside if
34
+ # do_something
35
+ # end
36
+ #
37
+ class EmptyLineBeforeComment < Base
38
+ extend AutoCorrector
39
+
40
+ MSG = "Add empty line before comment."
41
+
42
+ def on_new_investigation
43
+ processed_source.comments.each do |comment|
44
+ check_comment(comment)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def check_comment(comment)
51
+ return if inline_comment?(comment)
52
+ return if first_line?(comment)
53
+ return if preceded_by_empty_line?(comment)
54
+ return if preceded_by_comment?(comment)
55
+ return if preceded_by_block_start?(comment)
56
+
57
+ add_offense(comment) do |corrector|
58
+ corrector.insert_before(
59
+ comment.source_range.with(
60
+ begin_pos: comment.source_range.begin_pos - comment.source_range.column,
61
+ ),
62
+ "\n",
63
+ )
64
+ end
65
+ end
66
+
67
+ def inline_comment?(comment)
68
+ # Check if there's code before the comment on the same line
69
+ comment_line = processed_source.lines[comment.loc.line - 1]
70
+ return false unless comment_line
71
+
72
+ # Get the portion of the line before the comment
73
+ before_comment = comment_line[0...comment.loc.column]
74
+ before_comment && !before_comment.strip.empty?
75
+ end
76
+
77
+ def first_line?(comment)
78
+ comment.loc.line == 1
79
+ end
80
+
81
+ def preceded_by_empty_line?(comment)
82
+ prev_line_number = comment.loc.line - 1
83
+ return true if prev_line_number < 1
84
+
85
+ prev_line = processed_source.lines[prev_line_number - 1]
86
+ prev_line.nil? || prev_line.strip.empty?
87
+ end
88
+
89
+ def preceded_by_comment?(comment)
90
+ prev_line_number = comment.loc.line - 1
91
+ return false if prev_line_number < 1
92
+
93
+ processed_source.comments.any? { |c| c.loc.line == prev_line_number }
94
+ end
95
+
96
+ def preceded_by_block_start?(comment)
97
+ prev_line_number = comment.loc.line - 1
98
+ return false if prev_line_number < 1
99
+
100
+ prev_line = processed_source.lines[prev_line_number - 1]
101
+ return false unless prev_line
102
+
103
+ block_start_pattern?(prev_line)
104
+ end
105
+
106
+ def block_start_pattern?(line)
107
+ stripped = line.strip
108
+
109
+ # Class, module, method definitions
110
+ return true if stripped.match?(/\A(class|module|def)\s/)
111
+
112
+ # Control structures
113
+ return true if stripped.match?(/\A(if|unless|case|while|until|for|begin)\s/)
114
+ return true if stripped == "begin"
115
+
116
+ # Block openers (do or {)
117
+ return true if stripped.end_with?(" do", " do |", "\tdo")
118
+ return true if stripped.match?(/do\s*\|[^|]*\|\s*\z/)
119
+ return true if stripped.end_with?("{")
120
+ return true if stripped.match?(/\{\s*\|[^|]*\|\s*\z/)
121
+
122
+ # Rescue/ensure/else/elsif/when inside blocks
123
+ return true if stripped.match?(/\A(rescue|ensure|else|elsif|when)\b/)
124
+
125
+ # Private/protected/public access modifiers
126
+ return true if stripped.match?(/\A(private|protected|public)\s*\z/)
127
+
128
+ false
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # This cop enforces empty lines before and after multiline blocks
7
+ # such as `if/else` and `case/when`.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # work_for = data.work_done_for
12
+ # if data.present?
13
+ # creation_date = date1
14
+ # else
15
+ # creation_date = date2
16
+ # end
17
+ # legal_start_date = date3
18
+ #
19
+ # # good
20
+ # work_for = data.work_done_for
21
+ #
22
+ # if data.present?
23
+ # creation_date = date1
24
+ # else
25
+ # creation_date = date2
26
+ # end
27
+ #
28
+ # legal_start_date = date3
29
+ #
30
+ # # good - no blank line needed at beginning of method
31
+ # def foo
32
+ # if condition
33
+ # bar
34
+ # else
35
+ # baz
36
+ # end
37
+ #
38
+ # qux
39
+ # end
40
+ #
41
+ # # good - no blank line needed at end of method
42
+ # def foo
43
+ # bar
44
+ #
45
+ # if condition
46
+ # baz
47
+ # else
48
+ # qux
49
+ # end
50
+ # end
51
+ #
52
+ class EmptyLinesAroundMultilineBlock < Base
53
+ extend AutoCorrector
54
+
55
+ MSG_BEFORE = "Add empty line before multiline `%<keyword>s` block."
56
+ MSG_AFTER = "Add empty line after multiline `%<keyword>s` block."
57
+
58
+ def on_if(node)
59
+ return if node.ternary?
60
+ return if node.modifier_form?
61
+ return if node.elsif? # elsif is part of parent if, not a separate block
62
+ return unless multiline?(node)
63
+ return if part_of_expression?(node)
64
+
65
+ check_empty_line_before(node)
66
+ check_empty_line_after(node)
67
+ end
68
+
69
+ def part_of_expression?(node)
70
+ parent = node.parent
71
+ return false unless parent
72
+
73
+ # Part of assignment: foo = if ... end
74
+ return true if assignment_node?(parent)
75
+
76
+ # Part of setter call: obj.foo = if ... end
77
+ return true if parent.send_type? && parent.method_name.to_s.end_with?("=")
78
+
79
+ # Part of method arguments: foo(if ... end)
80
+ return true if parent.send_type? && parent.arguments.include?(node)
81
+
82
+ # Part of array: [if ... end]
83
+ return true if parent.array_type?
84
+
85
+ # Part of hash value: { key: if ... end }
86
+ return true if parent.pair_type?
87
+
88
+ false
89
+ end
90
+
91
+ def on_case(node)
92
+ return unless multiline?(node)
93
+
94
+ check_empty_line_before(node)
95
+ check_empty_line_after(node)
96
+ end
97
+
98
+ alias on_case_match on_case
99
+
100
+ def on_block(node)
101
+ return unless multiline?(node)
102
+ return if chained_block?(node) # e.g., expect { ... }.to raise_error
103
+ return if lambda_block?(node) # e.g., -> { ... } or -> do ... end
104
+
105
+ check_empty_line_before(node)
106
+ check_empty_line_after(node)
107
+ end
108
+
109
+ alias on_numblock on_block
110
+
111
+ def lambda_block?(node)
112
+ node.send_node&.lambda_literal?
113
+ end
114
+
115
+ def chained_block?(node)
116
+ # Skip blocks that are part of a method chain
117
+ # e.g., expect { ... }.to raise_error
118
+ # e.g., foo.map { ... }&.join (csend is safe navigation &.)
119
+ parent = node.parent
120
+ return true if (parent&.send_type? || parent&.csend_type?) && parent.receiver == node
121
+
122
+ # Skip blocks that are part of an assignment expression
123
+ # e.g., self.foo = items.map do ... end
124
+ return true if part_of_assignment?(node)
125
+
126
+ false
127
+ end
128
+
129
+ def part_of_assignment?(node)
130
+ parent = node.parent
131
+ return false unless parent
132
+
133
+ # Direct assignment: foo = bar.map do...end
134
+ return true if assignment_node?(parent)
135
+
136
+ # Assignment via setter: self.foo = bar.map do...end
137
+ return true if parent.send_type? && parent.method_name.to_s.end_with?("=")
138
+
139
+ # Hash value: { key: items.map do...end }
140
+ return true if parent.pair_type?
141
+
142
+ # Array element: [items.map do...end]
143
+ return true if parent.array_type?
144
+
145
+ false
146
+ end
147
+
148
+ def assignment_node?(node)
149
+ [:lvasgn, :ivasgn, :cvasgn, :gvasgn, :casgn, :masgn, :op_asgn, :or_asgn, :and_asgn].include?(node.type)
150
+ end
151
+
152
+ def preceded_by_paired_method?(node, prev_sibling)
153
+ # Skip blank line requirement for idiomatic pairings like desc + task
154
+ # Only applies to block nodes
155
+ return false unless node.block_type? || node.numblock_type?
156
+ return false unless prev_sibling&.send_type?
157
+ return false unless node.send_node
158
+
159
+ prev_method = prev_sibling.method_name
160
+ current_method = node.send_node.method_name
161
+
162
+ paired_methods?(prev_method, current_method)
163
+ end
164
+
165
+ def paired_methods?(prev_method, current_method)
166
+ # desc + task is idiomatic in rake files
167
+ prev_method == :desc && current_method == :task
168
+ end
169
+
170
+ def followed_by_rescue?(node)
171
+ # Don't require blank line before rescue clause
172
+ parent = node.parent
173
+ return false unless parent
174
+
175
+ # Check if parent is a rescue node and next sibling is a resbody
176
+ if parent.rescue_type?
177
+ siblings = parent.children
178
+ index = siblings.index(node)
179
+ return false unless index
180
+
181
+ next_sib = siblings[index + 1]
182
+ return next_sib&.resbody_type?
183
+ end
184
+
185
+ false
186
+ end
187
+
188
+ private
189
+
190
+ def multiline?(node)
191
+ node.loc.first_line != node.loc.last_line
192
+ end
193
+
194
+ def block_keyword(node)
195
+ if node.if_type?
196
+ node.keyword
197
+ elsif node.case_type? || node.case_match_type?
198
+ "case"
199
+ elsif node.block_type? || node.numblock_type?
200
+ "do...end"
201
+ else
202
+ "block"
203
+ end
204
+ end
205
+
206
+ def check_empty_line_before(node)
207
+ return if first_child_of_parent?(node)
208
+
209
+ prev_sibling = previous_sibling(node)
210
+ return unless prev_sibling
211
+ return if prev_sibling.is_a?(Symbol) # Skip non-node siblings
212
+ return if empty_line_between?(prev_sibling, node)
213
+ return if comment_line_before?(node)
214
+ return if preceded_by_paired_method?(node, prev_sibling)
215
+
216
+ keyword = block_keyword(node)
217
+
218
+ add_offense(node, message: format(MSG_BEFORE, keyword: keyword)) do |corrector|
219
+ # Insert newline after the previous line's end
220
+ corrector.insert_before(
221
+ node.source_range.with(
222
+ begin_pos: node.source_range.begin_pos - node.loc.column,
223
+ ),
224
+ "\n",
225
+ )
226
+ end
227
+ end
228
+
229
+ def check_empty_line_after(node)
230
+ return if last_child_of_parent?(node)
231
+ return if followed_by_rescue?(node)
232
+
233
+ next_sibling = next_sibling(node)
234
+ return unless next_sibling
235
+ return if next_sibling.is_a?(Symbol) # Skip non-node siblings
236
+ return if empty_line_between?(node, next_sibling)
237
+ return if comment_line_after?(node)
238
+
239
+ keyword = block_keyword(node)
240
+
241
+ add_offense(node.loc.end, message: format(MSG_AFTER, keyword: keyword)) do |corrector|
242
+ corrector.insert_after(node, "\n")
243
+ end
244
+ end
245
+
246
+ def first_child_of_parent?(node)
247
+ parent = node.parent
248
+ return true unless parent
249
+
250
+ # For block/def/class bodies, check within the body only
251
+ return true if only_child_in_body?(node, parent)
252
+
253
+ siblings = body_siblings(parent)
254
+ siblings.first == node
255
+ end
256
+
257
+ def last_child_of_parent?(node)
258
+ parent = node.parent
259
+ return true unless parent
260
+
261
+ # For block/def/class bodies, check within the body only
262
+ return true if only_child_in_body?(node, parent)
263
+
264
+ siblings = body_siblings(parent)
265
+ siblings.last == node
266
+ end
267
+
268
+ def only_child_in_body?(node, parent)
269
+ # When the node IS the body (not wrapped in begin), it's the only child
270
+ return true if parent.type == :block && parent.body == node
271
+ return true if parent.type == :def && parent.body == node
272
+ return true if parent.type == :defs && parent.body == node
273
+ return true if parent.type == :resbody && parent.body == node
274
+
275
+ # For if/unless, check if node is the only thing in the if-branch or else-branch
276
+ if parent.type == :if
277
+ return true if parent.if_branch == node
278
+ return true if parent.else_branch == node
279
+ end
280
+
281
+ # For case/when
282
+ return true if parent.type == :when && parent.body == node
283
+ return true if parent.type == :case && parent.else_branch == node
284
+
285
+ false
286
+ end
287
+
288
+ def body_siblings(parent)
289
+ # For begin nodes, all children are body siblings
290
+ # For other nodes, filter to valid siblings
291
+ parent.children.select { |c| valid_sibling?(c) }
292
+ end
293
+
294
+ def previous_sibling(node)
295
+ parent = node.parent
296
+ return unless parent
297
+
298
+ siblings = parent.children
299
+ index = siblings.index(node)
300
+ return unless index&.positive?
301
+
302
+ # Find previous node sibling (skip non-nodes and nodes without location)
303
+ (index - 1).downto(0) do |i|
304
+ sibling = siblings[i]
305
+ return sibling if valid_sibling?(sibling)
306
+ end
307
+
308
+ nil
309
+ end
310
+
311
+ def next_sibling(node)
312
+ parent = node.parent
313
+ return unless parent
314
+
315
+ siblings = parent.children
316
+ index = siblings.index(node)
317
+ return unless index
318
+
319
+ # Find next node sibling (skip non-nodes and nodes without location)
320
+ ((index + 1)...siblings.size).each do |i|
321
+ sibling = siblings[i]
322
+ return sibling if valid_sibling?(sibling)
323
+ end
324
+
325
+ nil
326
+ end
327
+
328
+ def valid_sibling?(sibling)
329
+ sibling.is_a?(RuboCop::AST::Node) &&
330
+ sibling.loc&.expression
331
+ end
332
+
333
+ def empty_line_between?(node1, node2)
334
+ return true unless node1.loc&.expression && node2.loc&.expression
335
+
336
+ line1 = node1.loc.last_line
337
+ line2 = node2.loc.first_line
338
+
339
+ # There's an empty line if there's more than 1 line between them
340
+ (line2 - line1) > 1
341
+ end
342
+
343
+ def comment_line_before?(node)
344
+ line_before = node.loc.first_line - 1
345
+ return false if line_before < 1
346
+
347
+ processed_source.comments.any? { |c| c.loc.line == line_before }
348
+ end
349
+
350
+ def comment_line_after?(node)
351
+ line_after = node.loc.last_line + 1
352
+ processed_source.comments.any? { |c| c.loc.line == line_after }
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sane
6
+ # Prohibits calling methods directly after `end`.
7
+ #
8
+ # This cop detects method calls chained directly on blocks, conditionals,
9
+ # and other structures that end with `end`. Such code is harder to read
10
+ # and should be refactored to assign the result to a variable first.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # if condition
15
+ # value
16
+ # end.foo
17
+ #
18
+ # # bad
19
+ # if condition
20
+ # value
21
+ # end&.foo
22
+ #
23
+ # # bad
24
+ # array.map do |item|
25
+ # transform(item)
26
+ # end.compact
27
+ #
28
+ # # good
29
+ # result = if condition
30
+ # value
31
+ # end
32
+ # result.foo
33
+ #
34
+ # # good
35
+ # result = array.map do |item|
36
+ # transform(item)
37
+ # end
38
+ # result.compact
39
+ #
40
+ class NoMethodCallAfterEnd < Base
41
+ MSG = "Do not call methods directly after `end`."
42
+
43
+ END_KEYWORD_NODES = [
44
+ :if, :case, :case_match, :while, :until, :for,
45
+ :kwbegin, :block, :numblock,
46
+ :def, :defs, :class, :module, :sclass,
47
+ ].freeze
48
+
49
+ BLOCK_NODES = [:block, :numblock].freeze
50
+
51
+ def on_send(node)
52
+ check_method_call_after_end(node)
53
+ end
54
+
55
+ def on_csend(node)
56
+ check_method_call_after_end(node)
57
+ end
58
+
59
+ private
60
+
61
+ def check_method_call_after_end(node)
62
+ receiver = node.receiver
63
+ return unless receiver
64
+ return unless ends_with_end_keyword?(receiver)
65
+
66
+ add_offense(node.loc.dot || node.loc.selector)
67
+ end
68
+
69
+ def ends_with_end_keyword?(node)
70
+ return false unless END_KEYWORD_NODES.include?(node.type)
71
+ return !node.braces? if BLOCK_NODES.include?(node.type)
72
+
73
+ true
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sane/disallow_methods"
4
+ require_relative "sane/conditional_assignment_allow_ternary"
5
+ require_relative "sane/empty_line_before_comment"
6
+ require_relative "sane/empty_lines_around_multiline_block"
7
+ require_relative "sane/no_method_call_after_end"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lint_roller"
4
+
5
+ module RuboCop
6
+ module Sane
7
+ # A plugin that integrates RuboCop Sane with RuboCop's plugin system.
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: "rubocop-sane",
12
+ version: VERSION,
13
+ homepage: "https://github.com/akodkod/rubocop-sane",
14
+ description: "Sane RuboCop cops for modern Ruby development.",
15
+ )
16
+ end
17
+
18
+ def supported?(context)
19
+ context.engine == :rubocop
20
+ end
21
+
22
+ def rules(_context)
23
+ LintRoller::Rules.new(
24
+ type: :path,
25
+ config_format: :rubocop,
26
+ value: RuboCop::Sane::CONFIG_DEFAULT,
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Sane
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ # RuboCop Sane project namespace
5
+ module Sane
6
+ class Error < StandardError; end
7
+
8
+ PROJECT_ROOT = Pathname.new(__dir__).parent.parent.expand_path.freeze
9
+ CONFIG_DEFAULT = PROJECT_ROOT.join("config", "default.yml").freeze
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "rubocop/sane"
6
+ require_relative "rubocop/sane/version"
7
+ require_relative "rubocop/sane/plugin"
8
+ require_relative "rubocop/cop/sane_cops"
@@ -0,0 +1,6 @@
1
+ module Rubocop
2
+ module Sane
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-sane
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Kodkod
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: lint_roller
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubocop
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.48.0
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 1.48.0
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ description: A collection of RuboCop cops that enforce sensible coding conventions
47
+ email:
48
+ - 678665+akodkod@users.noreply.github.com
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - ".rspec"
54
+ - ".rubocop.yml"
55
+ - ".vscode/cspell.json"
56
+ - ".vscode/project-words.txt"
57
+ - CHANGELOG.md
58
+ - CLAUDE.md
59
+ - CODE_OF_CONDUCT.md
60
+ - LICENSE.txt
61
+ - README.md
62
+ - Rakefile
63
+ - config/default.yml
64
+ - lib/rubocop-sane.rb
65
+ - lib/rubocop/cop/sane/conditional_assignment_allow_ternary.rb
66
+ - lib/rubocop/cop/sane/disallow_methods.rb
67
+ - lib/rubocop/cop/sane/empty_line_before_comment.rb
68
+ - lib/rubocop/cop/sane/empty_lines_around_multiline_block.rb
69
+ - lib/rubocop/cop/sane/no_method_call_after_end.rb
70
+ - lib/rubocop/cop/sane_cops.rb
71
+ - lib/rubocop/sane.rb
72
+ - lib/rubocop/sane/plugin.rb
73
+ - lib/rubocop/sane/version.rb
74
+ - sig/rubocop/sane.rbs
75
+ homepage: https://github.com/akodkod/rubocop-sane
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://github.com/akodkod/rubocop-sane
80
+ source_code_uri: https://github.com/akodkod/rubocop-sane
81
+ changelog_uri: https://github.com/akodkod/rubocop-sane/blob/main/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
83
+ default_lint_roller_plugin: RuboCop::Sane::Plugin
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 3.2.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.6.9
99
+ specification_version: 4
100
+ summary: Sane RuboCop cops for modern Ruby development
101
+ test_files: []