rubocop-hanami 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d0cdb245fcc1d369d11881b9ebc6064cc09dfe3acf0267fd2329fe3cd0f662c
4
- data.tar.gz: 9649a91707ea8b596f96edb4e6c636a3ed6fe1b323a1533c1395c032178b4116
3
+ metadata.gz: 7f6976191e56400636d7d6b59ab3be6b59fe8bcdbb83385412b7d8c49815c022
4
+ data.tar.gz: 8d27ebaacb50254b176b175c85c2170dc44e663a4e3a15858cc4d03ecda0c86a
5
5
  SHA512:
6
- metadata.gz: b0c2d75c9f70db90f82d4bb707b315a178972c6a761f681264f3b6739cffe155c80e76c74e253dcd8bd5ccd6e35e878410eb1b3989c641e5c9125493506da7b1
7
- data.tar.gz: 38031749617b42962b73ef90165d04ca176b5c2160265c3207a2a847f1314d70dc3886bda2e4feb865d9d9fed473635601e9b96279041c761345b029c5023770
6
+ metadata.gz: 9c7df42e38e04d36610528c38c52ca955d39109b0453e75f0222ba29b6d9631f3a001e16c0da165d89e57662137cdf94e1be8a54d11282976b179a045d9762c0
7
+ data.tar.gz: 0b7d37ba2fc067c567e9f3648080159cf7f694c682286213cdce6c589a85ad4d6078b0a6b354a1fec1a19f0faa9f3e7438214895160de67dc9ebe5fa7f74eae7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [v0.2.0] - 2026-10-07
11
+
12
+ ### Added
13
+
14
+ - `Hanami/ActionCallOverride`, which flags `def call` in an action, where `handle` belongs. A class counts as an
15
+ action when its file sits under `actions` or it inherits from one of `ParentClasses`, `Hanami::Action` by
16
+ default. A `call` that calls `super` passes.
17
+ - `Hanami/AppReferenceInSlice`, which flags `Hanami.app` in a slice, outside slice `config`. It leaves lookups such
18
+ as `Hanami.app["logger"]` to `Hanami/ContainerLookup` while that cop is on. `AppNamespaces` names the host app's
19
+ top-level modules, such as `MyApp`, to flag those too.
20
+ - `Hanami/ContainerLookup`, which flags a lookup by literal key on `Hanami.app`, on `Hanami.app.slices[...]` or on
21
+ any constant that ends in `Slice`, where `include Deps[...]` would do. This also catches code that reaches into
22
+ another slice's container without `import`. `AllowedReceivers` names constants to skip.
23
+ - `Hanami/EnvAccess`, which flags `ENV` outside settings, providers, `config/app.rb`, `config/puma.rb`, `bin`, `db`
24
+ and `spec`, so values go through Hanami settings and fail at boot when missing.
25
+ - `Hanami/PersistenceInAction`, off by default, which flags a repo or relation in an action's `include Deps[...]`.
26
+ `ForbiddenKeyPrefixes` match at the start of any segment, so an imported `search.repos.index` counts.
27
+ `AllowedKeys` takes strings and regular expressions to let keys through.
28
+ - `Hanami/ProviderTopLevelRequire`, which flags `require` and `require_relative` in a provider outside `prepare`
29
+ and `start`.
30
+ - `Hanami/RelationOutsideRepo`, which flags a relation, or the ROM container, in `include Deps[...]` outside
31
+ `repos`, `relations` and `db`. `RelationKeyPrefixes` and `ROMKeys` name the keys to flag.
32
+ - `Hanami/SliceNamespace`, which flags a top-level `module` or `class` in `slices/<name>/` that doesn't open the
33
+ slice's namespace, so a Zeitwerk load error shows up at lint time. `Inflections` names slices that don't
34
+ camelize plainly, such as `api: API`.
35
+ - `Hanami/UnusedDeps`, which flags a key in `include Deps[...]` whose accessor the class never reads. It can't see
36
+ reads in a subclass in another file, so disable it on keys meant for subclasses.
37
+ - `Hanami/UnvalidatedParams`, which flags `[]`, `fetch` and `dig` on `request.params` in an action that declares no
38
+ `params` or `contract`. `AllowedParentClasses` skips subclasses of a validated base action.
39
+ - `Hanami/UnwrappedStep`, which flags a call in an operation's flow to a method that returns `Success` or `Failure`
40
+ but isn't passed to `step`, since dry-operation drops the `Failure`. `FlowMethods` names the flow methods, `call`
41
+ by default, and the cop reads `operate_on` too.
42
+
43
+ ### Fixed
44
+
45
+ - `Hanami/SliceExports` no longer warns that it does not support `AllowedExports` when a config sets it.
46
+
10
47
  ## [v0.1.0] - 2026-10-07
11
48
 
12
49
  ### Added
@@ -16,5 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
53
  `ParentClasses` names the superclasses that mark a class as a slice, `Hanami::Slice` by default.
17
54
  `RequireExport: false` lets a slice leave out `export`.
18
55
 
19
- [Unreleased]: https://github.com/aaronmallen/rubocop-hanami/compare/0.1.0...HEAD
56
+ [Unreleased]: https://github.com/aaronmallen/rubocop-hanami/compare/0.2.0...HEAD
57
+ [v0.2.0]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.2.0
20
58
  [v0.1.0]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.1.0
data/config/default.yml CHANGED
@@ -5,7 +5,141 @@ Hanami/SliceExports:
5
5
  Include:
6
6
  - "**/config/slices/*.rb"
7
7
  - "**/slices/*/config/slice.rb"
8
+ AllowedExports: []
8
9
  ForbiddenExports: []
9
10
  ParentClasses:
10
11
  - Hanami::Slice
11
12
  RequireExport: true
13
+
14
+ Hanami/ContainerLookup:
15
+ Description: Checks for container lookups where `include Deps[...]` would do.
16
+ Enabled: true
17
+ VersionAdded: "0.2.0"
18
+ Include:
19
+ - "app/**/*.rb"
20
+ - "slices/**/*.rb"
21
+ - "lib/**/*.rb"
22
+ Exclude:
23
+ - "config/**/*"
24
+ - "slices/*/config/**/*"
25
+ - "spec/**/*"
26
+ AllowedReceivers: []
27
+
28
+ Hanami/AppReferenceInSlice:
29
+ Description: Checks for references to `Hanami.app` from a slice.
30
+ Enabled: true
31
+ VersionAdded: "0.2.0"
32
+ Include:
33
+ - "slices/**/*.rb"
34
+ Exclude:
35
+ - "slices/*/config/**/*"
36
+ AppNamespaces: []
37
+
38
+ Hanami/RelationOutsideRepo:
39
+ Description: Checks for relations in `include Deps[...]` outside a repo.
40
+ Enabled: true
41
+ VersionAdded: "0.2.0"
42
+ Include:
43
+ - "app/**/*.rb"
44
+ - "slices/**/*.rb"
45
+ Exclude:
46
+ - "app/db/**/*"
47
+ - "app/relations/**/*"
48
+ - "app/repos/**/*"
49
+ - "slices/*/config/**/*"
50
+ - "slices/*/db/**/*"
51
+ - "slices/*/relations/**/*"
52
+ - "slices/*/repos/**/*"
53
+ RelationKeyPrefixes:
54
+ - relations.
55
+ ROMKeys:
56
+ - db.rom
57
+ - persistence.rom
58
+
59
+ Hanami/PersistenceInAction:
60
+ Description: Checks for repos and relations in an action's `include Deps[...]`.
61
+ Enabled: false
62
+ VersionAdded: "0.2.0"
63
+ Include:
64
+ - "**/actions/**/*.rb"
65
+ AllowedKeys: []
66
+ ForbiddenKeyPrefixes:
67
+ - repos.
68
+ - relations.
69
+
70
+ Hanami/UnusedDeps:
71
+ Description: Checks for keys in `include Deps[...]` that the class never reads.
72
+ Enabled: true
73
+ VersionAdded: "0.2.0"
74
+ Include:
75
+ - "app/**/*.rb"
76
+ - "slices/**/*.rb"
77
+ - "lib/**/*.rb"
78
+
79
+ Hanami/EnvAccess:
80
+ Description: Checks for `ENV` outside settings and providers.
81
+ Enabled: true
82
+ VersionAdded: "0.2.0"
83
+ Include:
84
+ - "**/*.rb"
85
+ Exclude:
86
+ - "bin/**/*"
87
+ - "config.ru"
88
+ - "config/app.rb"
89
+ - "config/providers/**/*"
90
+ - "config/puma.rb"
91
+ - "config/settings.rb"
92
+ - "db/**/*"
93
+ - "Rakefile"
94
+ - "slices/*/config/providers/**/*"
95
+ - "slices/*/config/settings.rb"
96
+ - "spec/**/*"
97
+
98
+ Hanami/ActionCallOverride:
99
+ Description: Checks for `def call` in an action, where `handle` belongs.
100
+ Enabled: true
101
+ VersionAdded: "0.2.0"
102
+ Include:
103
+ - "app/**/*.rb"
104
+ - "slices/**/*.rb"
105
+ ParentClasses:
106
+ - Hanami::Action
107
+
108
+ Hanami/UnvalidatedParams:
109
+ Description: Checks for `request.params` reads in an action with no `params` or `contract`.
110
+ Enabled: true
111
+ VersionAdded: "0.2.0"
112
+ Include:
113
+ - "**/actions/**/*.rb"
114
+ AllowedParentClasses: []
115
+
116
+ Hanami/ProviderTopLevelRequire:
117
+ Description: Checks for `require` in a provider outside `prepare` and `start`.
118
+ Enabled: true
119
+ VersionAdded: "0.2.0"
120
+ Include:
121
+ - "**/config/providers/*.rb"
122
+ - "**/slices/*/config/providers/*.rb"
123
+
124
+ Hanami/UnwrappedStep:
125
+ Description: Checks for calls in an operation's flow that return a Result but skip `step`.
126
+ Enabled: true
127
+ VersionAdded: "0.2.0"
128
+ Include:
129
+ - "app/**/*.rb"
130
+ - "slices/**/*.rb"
131
+ - "lib/**/*.rb"
132
+ FlowMethods:
133
+ - call
134
+
135
+ Hanami/SliceNamespace:
136
+ Description: Checks that files in a slice open the slice's namespace.
137
+ Enabled: true
138
+ VersionAdded: "0.2.0"
139
+ Include:
140
+ - "slices/**/*.rb"
141
+ Exclude:
142
+ - "slices/*/config/routes.rb"
143
+ - "slices/*/config/settings.rb"
144
+ - "slices/*/db/**/*"
145
+ Inflections: {}
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for `def call` in an action. `Hanami::Action#call(env)` is the Rack entry point. It
7
+ # builds the request and response, runs callbacks and params validation, then calls
8
+ # `handle`. Defining `call` replaces all of that.
9
+ #
10
+ # A class counts as an action when it inherits from one of `ParentClasses` or lives in a file
11
+ # under `actions`. The cop skips a `call` that calls `super`, as a base action may wrap `call`
12
+ # on purpose.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # class Actions::Home::Show < App::Action
17
+ # def call(env)
18
+ # [200, {}, ["hi"]]
19
+ # end
20
+ # end
21
+ #
22
+ # # good
23
+ # class Actions::Home::Show < App::Action
24
+ # def handle(request, response)
25
+ # response.body = "hi"
26
+ # end
27
+ # end
28
+ class ActionCallOverride < Base
29
+ MSG = "Define `handle` instead of `call` in an action."
30
+
31
+ def on_def(node)
32
+ return unless node.method?(:call) && action?(node.each_ancestor(:class, :module, :sclass).first)
33
+ return if node.each_descendant(:super, :zsuper).any?
34
+
35
+ add_offense(node.loc.keyword.join(node.loc.name))
36
+ end
37
+
38
+ private
39
+
40
+ def action?(scope)
41
+ return false unless scope&.class_type?
42
+ return true if %r{(\A|/)actions/}.match?(processed_source.file_path)
43
+
44
+ parent = scope.parent_class
45
+ parent&.const_type? && Array(cop_config["ParentClasses"]).include?(parent.const_name)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for `Hanami.app` in a slice. A slice should depend only on its own container and what
7
+ # it imports. `Hanami.app` ties it to the host app, so it can't be moved, extracted or loaded
8
+ # alone.
9
+ #
10
+ # When `Hanami/ContainerLookup` is on, this cop leaves a lookup such as `Hanami.app["logger"]`
11
+ # to it, so a line isn't flagged twice. `AppNamespaces` names top-level constants that belong
12
+ # to the host app, such as `MyApp`, to flag those as well. It is empty by default because the
13
+ # cop can't learn the app's name from one file.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # class Admin::Actions::Users::Index < Admin::Action
18
+ # def handle(request, response)
19
+ # Hanami.app.settings.page_size
20
+ # end
21
+ # end
22
+ #
23
+ # # good
24
+ # class Admin::Actions::Users::Index < Admin::Action
25
+ # include Deps["settings"]
26
+ #
27
+ # def handle(request, response)
28
+ # settings.page_size
29
+ # end
30
+ # end
31
+ #
32
+ # @example AppNamespaces: ['MyApp']
33
+ # # bad
34
+ # MyApp::Types::String
35
+ class AppReferenceInSlice < Base
36
+ MSG = "Don't reference `%<name>s` from a slice; use `Deps` or `import`."
37
+ RESTRICT_ON_SEND = %i[app].freeze
38
+
39
+ def_node_matcher :hanami_app?, "(send (const {nil? cbase} :Hanami) :app)"
40
+
41
+ def_node_matcher :container_lookup?, <<~PATTERN
42
+ (send {#hanami_app? (send (send #hanami_app? :slices) :[] _)} :[] str)
43
+ PATTERN
44
+
45
+ def on_const(node)
46
+ return unless node.namespace.nil? || node.namespace.cbase_type?
47
+ return unless Array(cop_config["AppNamespaces"]).include?(node.short_name.to_s)
48
+
49
+ add_offense(node, message: format(MSG, name: node.short_name))
50
+ end
51
+
52
+ def on_send(node)
53
+ return unless hanami_app?(node)
54
+ return if left_to_container_lookup?(node)
55
+
56
+ add_offense(node, message: format(MSG, name: "Hanami.app"))
57
+ end
58
+
59
+ private
60
+
61
+ def left_to_container_lookup?(node)
62
+ return false unless config.cop_enabled?("Hanami/ContainerLookup")
63
+
64
+ [node.parent, node.parent&.parent&.parent].any? { |lookup| lookup && container_lookup?(lookup) }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for a container lookup by key where `include Deps[...]` would do. Each lookup hides a
7
+ # dependency from the constructor, so a test can't pass a stub through `new` and the class
8
+ # header no longer says what the class needs.
9
+ #
10
+ # The cop flags `[]` with a literal string on `Hanami.app`, on `Hanami.app.slices[...]` and on
11
+ # any constant that ends in `Slice`. It skips a key that isn't a literal string, since the
12
+ # message can't name it. `AllowedReceivers` names constants to skip, for an app that wraps
13
+ # the container on purpose.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # class Admin::Actions::Users::Show < Admin::Action
18
+ # def handle(request, response)
19
+ # user = Admin::Slice["repos.user_repo"].find(request.params[:id])
20
+ # end
21
+ # end
22
+ #
23
+ # # good
24
+ # class Admin::Actions::Users::Show < Admin::Action
25
+ # include Deps["repos.user_repo"]
26
+ #
27
+ # def handle(request, response)
28
+ # user = user_repo.find(request.params[:id])
29
+ # end
30
+ # end
31
+ #
32
+ # @example AllowedReceivers: ['Admin::Slice']
33
+ # # good
34
+ # Admin::Slice["repos.user_repo"]
35
+ class ContainerLookup < Base
36
+ MSG = "Use `include Deps[\"%<key>s\"]` instead of a container lookup."
37
+ RESTRICT_ON_SEND = %i[[]].freeze
38
+
39
+ def_node_matcher :hanami_app?, "(send (const {nil? cbase} :Hanami) :app)"
40
+
41
+ def_node_matcher :container_lookup, <<~PATTERN
42
+ (send ${(const _ :Slice) #hanami_app? (send (send #hanami_app? :slices) :[] _)} :[] (str $_))
43
+ PATTERN
44
+
45
+ def on_send(node)
46
+ container_lookup(node) do |receiver, key|
47
+ return if receiver.const_type? && Array(cop_config["AllowedReceivers"]).include?(receiver.const_name)
48
+
49
+ add_offense(node, message: format(MSG, key: key))
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for `ENV` outside settings and providers. Hanami settings give each value a name, a
7
+ # type and a check at boot. A raw `ENV` read skips all three, and a missing variable shows up
8
+ # at request time. Writes count too.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # class Mailers::Welcome
13
+ # def from = ENV.fetch("MAIL_FROM")
14
+ # end
15
+ #
16
+ # # good
17
+ # class Mailers::Welcome
18
+ # include Deps["settings"]
19
+ #
20
+ # def from = settings.mail_from
21
+ # end
22
+ class EnvAccess < Base
23
+ MSG = "Read settings, not `ENV`."
24
+ MSG_KEY = "Read `%<key>s` through settings, not `ENV`."
25
+
26
+ def_node_matcher :env?, "(const {nil? cbase} :ENV)"
27
+ def_node_matcher :env_key, "(send #env? _ (str $_) ...)"
28
+
29
+ def on_const(node)
30
+ return unless env?(node)
31
+
32
+ parent = node.parent
33
+ return add_offense(node, message: MSG) unless parent&.send_type? && parent.receiver.equal?(node)
34
+
35
+ key = env_key(parent)
36
+ add_offense(parent, message: key ? format(MSG_KEY, key: key) : MSG)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Reads the keys of `include Deps[...]`.
7
+ module DepsKeys
8
+ extend NodePattern::Macros
9
+
10
+ # The accessor name dry-auto_inject gives a plain key, from Dry::AutoInject::DependencyMap.
11
+ NAME = /([a-z_][a-zA-Z_0-9]*)\z/
12
+
13
+ def_node_matcher :deps_arguments, "(send nil? :include (send (const {nil? cbase} :Deps) :[] $...))"
14
+
15
+ private
16
+
17
+ # Yields each literal key string in `arguments`, with the name of the accessor it defines. A
18
+ # trailing hash sets aliases: `mailer: "mailers.welcome"` defines `mailer`.
19
+ def each_deps_key(arguments)
20
+ arguments.each do |argument|
21
+ if argument.str_type?
22
+ yield argument, argument.value[NAME, 1]
23
+ elsif argument.hash_type?
24
+ argument.pairs.each { |pair| yield pair.value, pair.key.value.to_s if literal_alias?(pair) }
25
+ end
26
+ end
27
+ end
28
+
29
+ def literal_alias?(pair)
30
+ pair.key.type?(:sym, :str) && pair.value.str_type?
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for an action that depends on a repo or relation through `include Deps[...]`. Some
7
+ # teams want actions to do only HTTP work and hand the rest to an operation, so persistence and
8
+ # business rules live in one place that tests can call without a request. This cop is off by
9
+ # default.
10
+ #
11
+ # `ForbiddenKeyPrefixes` match at the start of any segment of the key, so an imported key such
12
+ # as `search.repos.index` counts too. `AllowedKeys` takes strings, matched exactly, and regular
13
+ # expressions, for keys such as a read-only query repo.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # class Actions::Users::Create < App::Action
18
+ # include Deps["repos.user_repo"]
19
+ #
20
+ # def handle(request, response)
21
+ # user_repo.create(request.params[:user])
22
+ # end
23
+ # end
24
+ #
25
+ # # good
26
+ # class Actions::Users::Create < App::Action
27
+ # include Deps["operations.create_user"]
28
+ #
29
+ # def handle(request, response)
30
+ # create_user.call(request.params[:user])
31
+ # end
32
+ # end
33
+ #
34
+ # @example AllowedKeys: ['repos.user_queries']
35
+ # # good
36
+ # include Deps["repos.user_queries"]
37
+ class PersistenceInAction < Base
38
+ include DepsKeys
39
+
40
+ MSG = "Call an operation instead of `%<key>s` from an action."
41
+ RESTRICT_ON_SEND = %i[include].freeze
42
+
43
+ def on_send(node)
44
+ deps_arguments(node) do |arguments|
45
+ each_deps_key(arguments) do |key_node, _name|
46
+ add_offense(key_node, message: format(MSG, key: key_node.value)) if forbidden?(key_node.value)
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def allowed?(key)
54
+ Array(cop_config["AllowedKeys"]).any? do |pattern|
55
+ pattern.is_a?(Regexp) ? pattern.match?(key) : pattern == key
56
+ end
57
+ end
58
+
59
+ def forbidden?(key)
60
+ return false if allowed?(key)
61
+
62
+ Array(cop_config["ForbiddenKeyPrefixes"]).any? do |prefix|
63
+ key.start_with?(prefix) || key.include?(".#{prefix}")
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for `require` and `require_relative` in a provider file outside `prepare` and
7
+ # `start`. A top-level `require` runs when the file loads. Inside `prepare`, it runs only when
8
+ # the provider prepares, as the Hanami guides show.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # require "sidekiq"
13
+ #
14
+ # Hanami.app.register_provider(:sidekiq) do
15
+ # start do
16
+ # register "sidekiq", Sidekiq
17
+ # end
18
+ # end
19
+ #
20
+ # # good
21
+ # Hanami.app.register_provider(:sidekiq) do
22
+ # prepare do
23
+ # require "sidekiq"
24
+ # end
25
+ #
26
+ # start do
27
+ # register "sidekiq", Sidekiq
28
+ # end
29
+ # end
30
+ class ProviderTopLevelRequire < Base
31
+ MSG = "Move `%<require>s` into `prepare`."
32
+ RESTRICT_ON_SEND = %i[require require_relative].freeze
33
+
34
+ def on_send(node)
35
+ return unless node.receiver.nil?
36
+
37
+ block = node.each_ancestor(:any_block).first
38
+ return if block && %i[prepare start].include?(block.method_name)
39
+
40
+ add_offense(node, message: format(MSG, require: node.source))
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for a relation, or the ROM container that hands them out, in `include Deps[...]`
7
+ # outside a repo. Repos are the boundary to the database. When an action, operation or view
8
+ # queries a relation, query logic spreads across layers and the repo stops being the one place
9
+ # to change it.
10
+ #
11
+ # `RelationKeyPrefixes` names the start of a relation's key, and `ROMKeys` names the keys of
12
+ # the ROM container.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # class Operations::ListUsers
17
+ # include Deps["relations.users"]
18
+ #
19
+ # def call = users.where(active: true).to_a
20
+ # end
21
+ #
22
+ # # good
23
+ # class Operations::ListUsers
24
+ # include Deps["repos.user_repo"]
25
+ #
26
+ # def call = user_repo.active
27
+ # end
28
+ class RelationOutsideRepo < Base
29
+ include DepsKeys
30
+
31
+ MSG = "Use `%<key>s` only from a repo."
32
+ RESTRICT_ON_SEND = %i[include].freeze
33
+
34
+ def on_send(node)
35
+ deps_arguments(node) do |arguments|
36
+ each_deps_key(arguments) do |key_node, _name|
37
+ add_offense(key_node, message: format(MSG, key: key_node.value)) if relation?(key_node.value)
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def relation?(key)
45
+ Array(cop_config["RelationKeyPrefixes"]).any? { |prefix| key.start_with?(prefix) } ||
46
+ Array(cop_config["ROMKeys"]).include?(key)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -84,7 +84,7 @@ module RuboCop
84
84
  def message_for(key)
85
85
  return MSG_FORBIDDEN if matches?("ForbiddenExports", key)
86
86
 
87
- MSG_NOT_ALLOWED if cop_config.key?("AllowedExports") && !matches?("AllowedExports", key)
87
+ MSG_NOT_ALLOWED if Array(cop_config["AllowedExports"]).any? && !matches?("AllowedExports", key)
88
88
  end
89
89
 
90
90
  def slice_class?(node)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks that each top-level `module` and `class` in a file under `slices/<name>/` opens the
7
+ # slice's namespace. Zeitwerk expects `slices/admin/actions/users/index.rb` to define
8
+ # `Admin::Actions::Users::Index`. A mismatch raises at load time, often far from the file at
9
+ # fault.
10
+ #
11
+ # The cop camelizes the slice's directory, so `admin_panel` gives `AdminPanel`, and checks only
12
+ # the first segment of each name. Name slices that don't camelize plainly in `Inflections`. For
13
+ # nested slices, the innermost one counts.
14
+ #
15
+ # @example
16
+ # # slices/admin/actions/users/index.rb
17
+ #
18
+ # # bad
19
+ # module Backoffice
20
+ # module Actions
21
+ # end
22
+ # end
23
+ #
24
+ # # good
25
+ # module Admin
26
+ # module Actions
27
+ # end
28
+ # end
29
+ #
30
+ # @example Inflections: { api: API }
31
+ # # slices/api/action.rb
32
+ #
33
+ # # good
34
+ # class API::Action < MyApp::Action
35
+ # end
36
+ class SliceNamespace < Base
37
+ MSG = "Files in `slices/%<slice>s` belong in the `%<namespace>s` namespace."
38
+ SLICE = %r{(?:\A|/)slices/([^/]+)(?=/)}
39
+
40
+ def on_new_investigation
41
+ slice = slice_name
42
+ return unless slice && processed_source.ast
43
+
44
+ namespace = namespace_for(slice)
45
+ top_level_definitions.each do |definition|
46
+ next if root_name(definition.identifier) == namespace
47
+
48
+ add_offense(definition.identifier, message: format(MSG, slice: slice, namespace: namespace))
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def namespace_for(slice)
55
+ (cop_config["Inflections"] || {}).fetch(slice) { slice.split("_").map(&:capitalize).join }.to_s
56
+ end
57
+
58
+ def root_name(const)
59
+ const = const.namespace while const.namespace&.const_type?
60
+ const.short_name.to_s
61
+ end
62
+
63
+ def slice_name
64
+ processed_source.file_path.to_s.scan(SLICE).last&.first
65
+ end
66
+
67
+ def top_level_definitions
68
+ ast = processed_source.ast
69
+ (ast.begin_type? ? ast.children : [ast]).select { |node| node.type?(:module, :class) }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for a key in `include Deps[...]` whose accessor the class never reads. A stale
7
+ # dependency still gets resolved, still boots its provider and still shows up as a constructor
8
+ # argument.
9
+ #
10
+ # A read is a bare call or a call on `self`, the instance variable the kwargs strategy sets, or
11
+ # `send`, `public_send`, `__send__` or `method` with a literal symbol. The cop skips a class
12
+ # that passes `Deps` anything but literal keys, or calls `send` with a name it can't read. It
13
+ # skips modules, since `include Deps` in a module serves whatever class includes it.
14
+ #
15
+ # The cop sees one file, so it flags a dependency that only a subclass reads. Disable it with a
16
+ # comment on that key.
17
+ #
18
+ # @example
19
+ # # bad
20
+ # class Operations::CreateUser
21
+ # include Deps["repos.user_repo", "mailers.welcome"]
22
+ #
23
+ # def call(input)
24
+ # user_repo.create(input)
25
+ # end
26
+ # end
27
+ #
28
+ # # good
29
+ # class Operations::CreateUser
30
+ # include Deps["repos.user_repo"]
31
+ #
32
+ # def call(input)
33
+ # user_repo.create(input)
34
+ # end
35
+ # end
36
+ class UnusedDeps < Base
37
+ include DepsKeys
38
+
39
+ MSG = "`%<name>s` from `Deps[\"%<key>s\"]` is never used."
40
+
41
+ def_node_matcher :dynamic_send?, "(send _ {:send :public_send :__send__} !sym ...)"
42
+ def_node_matcher :literal_send, "(send _ {:send :public_send :__send__ :method} (sym $_) ...)"
43
+
44
+ def on_class(node)
45
+ arguments = deps_arguments_in(node)
46
+ return unless checkable?(node, arguments)
47
+
48
+ used = used_names(node)
49
+ each_deps_key(arguments) do |key_node, name|
50
+ next if name.nil? || used.include?(name)
51
+
52
+ add_offense(key_node, message: format(MSG, name: name, key: key_node.value))
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def checkable?(node, arguments)
59
+ return false if arguments.empty? || !arguments.all? { |argument| literal?(argument) }
60
+
61
+ node.each_descendant(:send).none? { |send| dynamic_send?(send) }
62
+ end
63
+
64
+ def deps_arguments_in(node)
65
+ body = node.body
66
+ children = body&.begin_type? ? body.children : [body]
67
+ children.compact.filter_map { |child| deps_arguments(child) }.flatten
68
+ end
69
+
70
+ def literal?(argument)
71
+ argument.str_type? || (argument.hash_type? && argument.pairs.all? { |pair| literal_alias?(pair) })
72
+ end
73
+
74
+ def used_names(node)
75
+ node.each_descendant(:send, :ivar).to_set do |read|
76
+ next read.children.first.to_s.delete_prefix("@") if read.ivar_type?
77
+
78
+ literal_send(read)&.to_s || (read.method_name.to_s if read.receiver.nil? || read.receiver.self_type?)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for a key read from `request.params` in an action that declares no `params` or
7
+ # `contract`. Without a schema, `request.params` holds raw input: strings, any keys, no
8
+ # coercion.
9
+ #
10
+ # The cop reads `[]`, `fetch` and `dig` on the params of the first argument of `handle`,
11
+ # whatever it is named. It can't see a `params` declared in a parent action, so
12
+ # `AllowedParentClasses` names validated base classes whose subclasses it skips.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # class Actions::Users::Show < App::Action
17
+ # def handle(request, response)
18
+ # id = request.params[:id]
19
+ # end
20
+ # end
21
+ #
22
+ # # good
23
+ # class Actions::Users::Show < App::Action
24
+ # params do
25
+ # required(:id).filled(:integer)
26
+ # end
27
+ #
28
+ # def handle(request, response)
29
+ # halt 422 unless request.params.valid?
30
+ #
31
+ # id = request.params[:id]
32
+ # end
33
+ # end
34
+ class UnvalidatedParams < Base
35
+ MSG = "Declare `params` or `contract` before reading `request.params`."
36
+
37
+ def_node_matcher :schema?, "{(send nil? {:params :contract} _) (block (send nil? {:params :contract}) ...)}"
38
+ def_node_matcher :params_read?, "(send (send (lvar %1) :params) {:[] :fetch :dig} ...)"
39
+
40
+ def on_def(node)
41
+ request = node.arguments.first if node.method?(:handle)
42
+ return unless request&.arg_type? && unvalidated?(node.each_ancestor(:class, :module, :sclass).first)
43
+
44
+ node.each_descendant(:send) { |send| add_offense(send) if params_read?(send, request.name) }
45
+ end
46
+
47
+ private
48
+
49
+ def allowed_parent?(scope)
50
+ parent = scope.parent_class
51
+ parent&.const_type? && Array(cop_config["AllowedParentClasses"]).include?(parent.const_name)
52
+ end
53
+
54
+ def declares_schema?(scope)
55
+ body = scope.body
56
+ (body&.begin_type? ? body.children : [body]).any? { |child| child && schema?(child) }
57
+ end
58
+
59
+ def unvalidated?(scope)
60
+ scope&.class_type? && !allowed_parent?(scope) && !declares_schema?(scope)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Hanami
6
+ # Checks for a call to a method that returns a Result, in an operation's flow, that isn't
7
+ # passed to `step`. dry-operation halts on failure only through `step`. A bare call drops the
8
+ # `Failure` and the flow carries on as if it worked.
9
+ #
10
+ # With no types to go on, the cop works within one class. A method returns a Result when its
11
+ # last expression, or a `return`, is `Success(...)` or `Failure(...)`. A flow method is one of
12
+ # `FlowMethods` or a name the class passes to `operate_on`. The cop skips a call whose value
13
+ # is assigned, used as a receiver or passed on. It can't see Results from injected
14
+ # dependencies.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # class Operations::CreateUser < App::Operation
19
+ # def call(input)
20
+ # validate(input)
21
+ # user_repo.create(input)
22
+ # end
23
+ #
24
+ # private
25
+ #
26
+ # def validate(input)
27
+ # input[:email] ? Success(input) : Failure(:no_email)
28
+ # end
29
+ # end
30
+ #
31
+ # # good
32
+ # class Operations::CreateUser < App::Operation
33
+ # def call(input)
34
+ # attrs = step validate(input)
35
+ # step create(attrs)
36
+ # end
37
+ # end
38
+ class UnwrappedStep < Base
39
+ MSG = "`%<name>s` returns a Result; wrap it in `step`."
40
+
41
+ def_node_matcher :result?, "(send nil? {:Success :Failure} ...)"
42
+ def_node_search :operate_on, "(send nil? :operate_on $...)"
43
+
44
+ def on_class(node)
45
+ defs = node.each_descendant(:def).to_a
46
+ results = defs.select { |defn| returns_result?(defn) }.to_set(&:method_name)
47
+ return if results.empty?
48
+
49
+ flows = flow_methods(node)
50
+ defs.select { |defn| flows.include?(defn.method_name) }.each { |defn| check_flow(defn, results) }
51
+ end
52
+
53
+ private
54
+
55
+ def check_flow(defn, results)
56
+ defn.each_descendant(:send) do |send|
57
+ next unless send.receiver.nil? && results.include?(send.method_name) && statement?(send)
58
+
59
+ add_offense(send, message: format(MSG, name: send.method_name))
60
+ end
61
+ end
62
+
63
+ def flow_methods(node)
64
+ names = operate_on(node).flat_map { |arguments| arguments.select(&:sym_type?).map(&:value) }
65
+ (Array(cop_config["FlowMethods"]).map(&:to_sym) + names).to_set
66
+ end
67
+
68
+ def returns_result?(defn)
69
+ defn.each_descendant(:return).any? { |ret| ret.children.first && result?(ret.children.first) } ||
70
+ tail_result?(defn.body)
71
+ end
72
+
73
+ def statement?(send)
74
+ parent = send.parent
75
+ parent.type?(:begin, :kwbegin) || (parent.type?(:def, :any_block) && parent.body.equal?(send))
76
+ end
77
+
78
+ def tail_result?(node)
79
+ return result?(node) if node&.send_type?
80
+
81
+ case node&.type
82
+ when :begin, :kwbegin then tail_result?(node.children.last)
83
+ when :if, :case then node.branches.any? { |branch| tail_result?(branch) }
84
+ else false
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -3,4 +3,17 @@
3
3
  require "rubocop"
4
4
 
5
5
  require_relative "rubocop/hanami/plugin"
6
+ require_relative "rubocop/cop/hanami/mixin/deps_keys"
7
+
8
+ require_relative "rubocop/cop/hanami/action_call_override"
9
+ require_relative "rubocop/cop/hanami/app_reference_in_slice"
10
+ require_relative "rubocop/cop/hanami/container_lookup"
11
+ require_relative "rubocop/cop/hanami/env_access"
12
+ require_relative "rubocop/cop/hanami/persistence_in_action"
13
+ require_relative "rubocop/cop/hanami/provider_top_level_require"
14
+ require_relative "rubocop/cop/hanami/relation_outside_repo"
6
15
  require_relative "rubocop/cop/hanami/slice_exports"
16
+ require_relative "rubocop/cop/hanami/slice_namespace"
17
+ require_relative "rubocop/cop/hanami/unused_deps"
18
+ require_relative "rubocop/cop/hanami/unvalidated_params"
19
+ require_relative "rubocop/cop/hanami/unwrapped_step"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-hanami
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Allen
@@ -48,7 +48,19 @@ files:
48
48
  - LICENSE
49
49
  - config/default.yml
50
50
  - lib/rubocop-hanami.rb
51
+ - lib/rubocop/cop/hanami/action_call_override.rb
52
+ - lib/rubocop/cop/hanami/app_reference_in_slice.rb
53
+ - lib/rubocop/cop/hanami/container_lookup.rb
54
+ - lib/rubocop/cop/hanami/env_access.rb
55
+ - lib/rubocop/cop/hanami/mixin/deps_keys.rb
56
+ - lib/rubocop/cop/hanami/persistence_in_action.rb
57
+ - lib/rubocop/cop/hanami/provider_top_level_require.rb
58
+ - lib/rubocop/cop/hanami/relation_outside_repo.rb
51
59
  - lib/rubocop/cop/hanami/slice_exports.rb
60
+ - lib/rubocop/cop/hanami/slice_namespace.rb
61
+ - lib/rubocop/cop/hanami/unused_deps.rb
62
+ - lib/rubocop/cop/hanami/unvalidated_params.rb
63
+ - lib/rubocop/cop/hanami/unwrapped_step.rb
52
64
  - lib/rubocop/hanami/plugin.rb
53
65
  homepage: https://github.com/aaronmallen/rubocop-hanami
54
66
  licenses: