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 +4 -4
- data/CHANGELOG.md +39 -1
- data/config/default.yml +134 -0
- data/lib/rubocop/cop/hanami/action_call_override.rb +50 -0
- data/lib/rubocop/cop/hanami/app_reference_in_slice.rb +69 -0
- data/lib/rubocop/cop/hanami/container_lookup.rb +55 -0
- data/lib/rubocop/cop/hanami/env_access.rb +41 -0
- data/lib/rubocop/cop/hanami/mixin/deps_keys.rb +35 -0
- data/lib/rubocop/cop/hanami/persistence_in_action.rb +69 -0
- data/lib/rubocop/cop/hanami/provider_top_level_require.rb +45 -0
- data/lib/rubocop/cop/hanami/relation_outside_repo.rb +51 -0
- data/lib/rubocop/cop/hanami/slice_exports.rb +1 -1
- data/lib/rubocop/cop/hanami/slice_namespace.rb +74 -0
- data/lib/rubocop/cop/hanami/unused_deps.rb +84 -0
- data/lib/rubocop/cop/hanami/unvalidated_params.rb +65 -0
- data/lib/rubocop/cop/hanami/unwrapped_step.rb +90 -0
- data/lib/rubocop-hanami.rb +13 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f6976191e56400636d7d6b59ab3be6b59fe8bcdbb83385412b7d8c49815c022
|
|
4
|
+
data.tar.gz: 8d27ebaacb50254b176b175c85c2170dc44e663a4e3a15858cc4d03ecda0c86a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
data/lib/rubocop-hanami.rb
CHANGED
|
@@ -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.
|
|
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:
|