rubocop-guardrails 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: d8c264ff422c6fd3a97c216a8473d3a267c20e141f3ba5a7afba72ae37d06ed7
4
+ data.tar.gz: 99a6cfaa8055b4616badb1a37feb9e4882ca40491fbc175fe730ee58e453c6de
5
+ SHA512:
6
+ metadata.gz: 9d7da48d6b71734a2db38215c9b9e443fd9232cc44b2b71d8f3af62013139a44a6fef26903795ecc1a12a77e639e7ba6d680dda8526cf6d6961f8522c8418dbe
7
+ data.tar.gz: 2e9828ef7340c968362b26088072752765dce52c391fa0162bccec8698a65a4cc6cb65419515064c1018ff446236f6a094598e2817e13691b2da244133223c86
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Carl Dawson
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,45 @@
1
+ # rubocop-guardrails
2
+
3
+ RuboCop cops that keep Rails app on the golden path.
4
+
5
+ Rails is a convention-over-configuration framework, but it's easy to drift — especially when AI coding agents are involved. Agents pull from a broad training set and love to introduce service objects, decorators, form objects, and other patterns that aren't advocated for by the framework itself. These cops push back, keeping your app conventional: rich models, RESTful controllers, shallow jobs, and nothing in between.
6
+
7
+ ## Installation
8
+
9
+ Add it to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem "rubocop-guardrails", require: false
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### RuboCop plugin system (>= 1.72)
18
+
19
+ Add to your `.rubocop.yml`:
20
+
21
+ ```yaml
22
+ plugins:
23
+ - rubocop-guardrails
24
+ ```
25
+
26
+ ### Legacy
27
+
28
+ ```yaml
29
+ require:
30
+ - rubocop-guardrails
31
+ ```
32
+
33
+ ## Development
34
+
35
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
36
+
37
+ To generate a new cop:
38
+
39
+ ```bash
40
+ bundle exec rake 'new_cop[Guardrails/CopName]'
41
+ ```
42
+
43
+ ## License
44
+
45
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
11
+
12
+ desc 'Generate a new cop with a template'
13
+ task :new_cop, [:cop] do |_task, args|
14
+ require 'rubocop'
15
+
16
+ cop_name = args.fetch(:cop) do
17
+ warn 'usage: bundle exec rake new_cop[Department/Name]'
18
+ exit!
19
+ end
20
+
21
+ generator = RuboCop::Cop::Generator.new(cop_name)
22
+
23
+ generator.write_source
24
+ generator.write_spec
25
+ generator.inject_require(root_file_path: 'lib/rubocop/cop/guardrails_cops.rb')
26
+ generator.inject_config(config_file_path: 'config/default.yml')
27
+
28
+ puts generator.todo
29
+ end
@@ -0,0 +1,18 @@
1
+ Guardrails/NoServiceObjects:
2
+ Enabled: true
3
+ Description: 'Disallows service objects.'
4
+ Include:
5
+ - 'app/services/**/*.rb'
6
+
7
+ Guardrails/RestfulActions:
8
+ Enabled: true
9
+ Description: 'Prevents non-RESTful controller actions.'
10
+ Include:
11
+ - 'app/controllers/**/*.rb'
12
+
13
+ Guardrails/RestfulRoutes:
14
+ Enabled: true
15
+ Description: 'Detects non-RESTful route definitions.'
16
+ Include:
17
+ - 'config/routes.rb'
18
+ - 'config/routes/**/*.rb'
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Guardrails
6
+ # Disallows service objects.
7
+ #
8
+ # Service objects are a common pattern but they aren't the Rails way.
9
+ # Business logic belongs in your models — ActiveRecord models and
10
+ # plain old Ruby objects that live in `app/models`.
11
+ #
12
+ # @example
13
+ # # bad - app/services/post_publisher.rb
14
+ # class PostPublisher
15
+ # def call
16
+ # post.update!(published: true)
17
+ # end
18
+ # end
19
+ #
20
+ # # good - logic belongs in the model
21
+ # class Post < ApplicationRecord
22
+ # def publish
23
+ # update!(published: true)
24
+ # end
25
+ # end
26
+ class NoServiceObjects < Base
27
+ MSG = 'Avoid service objects. This logic belongs in your models.'
28
+
29
+ def on_class(node)
30
+ add_offense(node.loc.name)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Guardrails
6
+ # Prevents non-RESTful actions in controllers.
7
+ #
8
+ # Rails advocates for RESTful resources. When you need a custom
9
+ # action, extract a new controller with standard RESTful actions.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # class PostsController < ApplicationController
14
+ # def publish
15
+ # end
16
+ # end
17
+ #
18
+ # # good - extract a new resource
19
+ # class Posts::PublicationsController < ApplicationController
20
+ # def create
21
+ # end
22
+ # end
23
+ class RestfulActions < Base
24
+ MSG = 'Non-RESTful action `%<method>s`. Add a new resource to represent this action.'
25
+
26
+ RESTFUL_ACTIONS = %i[index show new create edit update destroy].to_set.freeze
27
+
28
+ def on_def(node)
29
+ return if RESTFUL_ACTIONS.include?(node.method_name)
30
+ return unless in_class?(node)
31
+ return unless public_method?(node)
32
+
33
+ add_offense(node.loc.name, message: format(MSG, method: node.method_name))
34
+ end
35
+
36
+ private
37
+
38
+ def in_class?(node)
39
+ node.each_ancestor(:class).any?
40
+ end
41
+
42
+ def public_method?(node)
43
+ return false if inline_visibility_modifier?(node)
44
+
45
+ effective_visibility(node) == :public
46
+ end
47
+
48
+ def inline_visibility_modifier?(node)
49
+ node.parent&.send_type? &&
50
+ %i[private protected].include?(node.parent.method_name)
51
+ end
52
+
53
+ def effective_visibility(node)
54
+ body = node.parent
55
+ return :public unless body&.begin_type?
56
+
57
+ body.children
58
+ .take_while { |child| !child.equal?(node) }
59
+ .select { |child| visibility_modifier?(child) }
60
+ .last&.method_name || :public
61
+ end
62
+
63
+ def visibility_modifier?(node)
64
+ node.send_type? &&
65
+ node.arguments.empty? &&
66
+ %i[private protected public].include?(node.method_name)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Guardrails
6
+ # Detects non-RESTful route definitions.
7
+ #
8
+ # Routes should use `resources` and `resource` exclusively. When you
9
+ # need a custom action, extract a new resource rather than adding
10
+ # bare HTTP verb routes or `member`/`collection` blocks.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # get '/posts/:id/publish', to: 'posts#publish'
15
+ #
16
+ # resources :posts do
17
+ # member do
18
+ # get :publish
19
+ # end
20
+ # end
21
+ #
22
+ # # good - extract a new resource
23
+ # resources :posts do
24
+ # resource :publication, only: :create
25
+ # end
26
+ class RestfulRoutes < Base
27
+ MSG_VERB = 'Use `resources` or `resource` instead of bare HTTP verb routes.'
28
+ MSG_MEMBER = 'Use a new `resources` instead of `member` routes.'
29
+ MSG_COLLECTION = 'Use a new `resources` instead of `collection` routes.'
30
+
31
+ HTTP_VERBS = %i[get post put patch delete match].to_set.freeze
32
+
33
+ RESTRICT_ON_SEND = [*HTTP_VERBS, :member, :collection].freeze
34
+
35
+ # @!method route_draw_block?(node)
36
+ def_node_matcher :route_draw_block?, <<~PATTERN
37
+ (block (send (send (send (const nil? :Rails) :application) :routes) :draw) ...)
38
+ PATTERN
39
+
40
+ def on_send(node)
41
+ return unless inside_routes_draw?(node)
42
+
43
+ if HTTP_VERBS.include?(node.method_name)
44
+ return if inside_member_or_collection?(node)
45
+
46
+ add_offense(node.loc.selector, message: MSG_VERB)
47
+ elsif node.method_name == :member
48
+ add_offense(node.loc.selector, message: MSG_MEMBER)
49
+ elsif node.method_name == :collection
50
+ add_offense(node.loc.selector, message: MSG_COLLECTION)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def inside_routes_draw?(node)
57
+ node.each_ancestor(:block).any? { |block| route_draw_block?(block) }
58
+ end
59
+
60
+ def inside_member_or_collection?(node)
61
+ node.each_ancestor(:block).any? do |block|
62
+ block.send_node.method_name == :member || block.send_node.method_name == :collection
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'guardrails/no_service_objects'
4
+ require_relative 'guardrails/restful_actions'
5
+ require_relative 'guardrails/restful_routes'
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ module RuboCop
6
+ module Guardrails
7
+ # A plugin that integrates rubocop-guardrails with RuboCop's plugin system.
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: 'rubocop-guardrails',
12
+ version: VERSION,
13
+ homepage: 'https://github.com/carldawson/rubocop-guardrails',
14
+ description: 'RuboCop cops that keep Rails apps on the golden path'
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: Pathname.new(__dir__).join('../../../config/default.yml')
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Guardrails
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'guardrails/version'
4
+
5
+ module RuboCop
6
+ # RuboCop cops that keep Rails apps on the golden path.
7
+ module Guardrails
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ require_relative 'rubocop/guardrails'
6
+ require_relative 'rubocop/guardrails/version'
7
+ require_relative 'rubocop/guardrails/plugin'
8
+
9
+ require_relative 'rubocop/cop/guardrails_cops'
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-guardrails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Carl Dawson
8
+ bindir: bin
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.72.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.72.2
40
+ description: Opinionated cops for developers who prefer conventional Rails — rich
41
+ models, RESTful controllers, shallow jobs, no service objects.
42
+ email:
43
+ - carldawson@hey.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE.txt
49
+ - README.md
50
+ - Rakefile
51
+ - config/default.yml
52
+ - lib/rubocop-guardrails.rb
53
+ - lib/rubocop/cop/guardrails/no_service_objects.rb
54
+ - lib/rubocop/cop/guardrails/restful_actions.rb
55
+ - lib/rubocop/cop/guardrails/restful_routes.rb
56
+ - lib/rubocop/cop/guardrails_cops.rb
57
+ - lib/rubocop/guardrails.rb
58
+ - lib/rubocop/guardrails/plugin.rb
59
+ - lib/rubocop/guardrails/version.rb
60
+ homepage: https://github.com/carldaws/rubocop-guardrails
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/carldaws/rubocop-guardrails
65
+ source_code_uri: https://github.com/carldaws/rubocop-guardrails
66
+ changelog_uri: https://github.com/carldaws/rubocop-guardrails/blob/main/CHANGELOG.md
67
+ default_lint_roller_plugin: RuboCop::Guardrails::Plugin
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.1.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.6
84
+ specification_version: 4
85
+ summary: RuboCop cops that keep Rails apps on the golden path
86
+ test_files: []