rubocop-hanami 0.1.0 → 0.2.1
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 +64 -1
- data/config/default.yml +135 -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 +107 -0
- data/lib/rubocop/cop/hanami/unused_deps.rb +137 -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/class_index.rb +153 -0
- data/lib/rubocop-hanami.rb +14 -0
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b66d94028f3350e9dc3966897abca014e9ccad980d431858e664b43421293cd2
|
|
4
|
+
data.tar.gz: 461f52c846ff69d42d92f1ba5e9f88259b88e32607f0f61130e0d650e359e95d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c675f410f6986ad1f8d8cce76f009ed5bdec6b33311d1ce4d49c2f0086d301f17cd4dd1f0d93e23406edad78d90ea5429f08bb50927fc2c358e94cda0a8c9e05
|
|
7
|
+
data.tar.gz: 83e3270654f1717d865a996e5257becb33edc829cb5ce1d8bd0722762586996465683d6d0793c0bb9246f30e0deb9ec21a79171150d1698346e3fd151eba4161
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [v0.2.1] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `AllowedNames` for `Hanami/UnusedDeps`, a list of accessor names the cop never flags in any class. Use it for names
|
|
15
|
+
read somewhere the cop can't follow. It also hides a real unused dependency of that name, so keep the list short.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `Hanami/UnusedDeps` counts a dependency as used when a parent class, a subclass at any depth, or a module either
|
|
20
|
+
one includes reads it. A base operation that reads `contract` while each subclass injects its own no longer flags
|
|
21
|
+
every subclass. The cop parses every file under `app`, `lib` and `slices` to follow them, and stops at constants
|
|
22
|
+
the project doesn't define, such as `Hanami::Action`.
|
|
23
|
+
- While `Hanami/UnusedDeps` is on, any change to a file under `app`, `lib` or `slices` clears RuboCop's cached
|
|
24
|
+
results for every cop and every file, since one class's result now depends on others.
|
|
25
|
+
- `Hanami/SliceNamespace` reads the acronyms in `config/app.rb`, so `inflections.acronym "TMAS"` turns
|
|
26
|
+
`tmas_gate_counts` into `TMASGateCounts` with no cop config. `Inflections` keys now match word by word as well as
|
|
27
|
+
by whole slice name.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `Hanami/UnusedDeps` flagged a dependency that `initialize` took as a keyword and read in its body. A keyword only
|
|
32
|
+
passed on to `super` still counts as unused.
|
|
33
|
+
|
|
34
|
+
## [v0.2.0] - 2026-10-07
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `Hanami/ActionCallOverride`, which flags `def call` in an action, where `handle` belongs. A class counts as an
|
|
39
|
+
action when its file sits under `actions` or it inherits from one of `ParentClasses`, `Hanami::Action` by
|
|
40
|
+
default. A `call` that calls `super` passes.
|
|
41
|
+
- `Hanami/AppReferenceInSlice`, which flags `Hanami.app` in a slice, outside slice `config`. It leaves lookups such
|
|
42
|
+
as `Hanami.app["logger"]` to `Hanami/ContainerLookup` while that cop is on. `AppNamespaces` names the host app's
|
|
43
|
+
top-level modules, such as `MyApp`, to flag those too.
|
|
44
|
+
- `Hanami/ContainerLookup`, which flags a lookup by literal key on `Hanami.app`, on `Hanami.app.slices[...]` or on
|
|
45
|
+
any constant that ends in `Slice`, where `include Deps[...]` would do. This also catches code that reaches into
|
|
46
|
+
another slice's container without `import`. `AllowedReceivers` names constants to skip.
|
|
47
|
+
- `Hanami/EnvAccess`, which flags `ENV` outside settings, providers, `config/app.rb`, `config/puma.rb`, `bin`, `db`
|
|
48
|
+
and `spec`, so values go through Hanami settings and fail at boot when missing.
|
|
49
|
+
- `Hanami/PersistenceInAction`, off by default, which flags a repo or relation in an action's `include Deps[...]`.
|
|
50
|
+
`ForbiddenKeyPrefixes` match at the start of any segment, so an imported `search.repos.index` counts.
|
|
51
|
+
`AllowedKeys` takes strings and regular expressions to let keys through.
|
|
52
|
+
- `Hanami/ProviderTopLevelRequire`, which flags `require` and `require_relative` in a provider outside `prepare`
|
|
53
|
+
and `start`.
|
|
54
|
+
- `Hanami/RelationOutsideRepo`, which flags a relation, or the ROM container, in `include Deps[...]` outside
|
|
55
|
+
`repos`, `relations` and `db`. `RelationKeyPrefixes` and `ROMKeys` name the keys to flag.
|
|
56
|
+
- `Hanami/SliceNamespace`, which flags a top-level `module` or `class` in `slices/<name>/` that doesn't open the
|
|
57
|
+
slice's namespace, so a Zeitwerk load error shows up at lint time. `Inflections` names slices that don't
|
|
58
|
+
camelize plainly, such as `api: API`.
|
|
59
|
+
- `Hanami/UnusedDeps`, which flags a key in `include Deps[...]` whose accessor the class never reads. It can't see
|
|
60
|
+
reads in a subclass in another file, so disable it on keys meant for subclasses.
|
|
61
|
+
- `Hanami/UnvalidatedParams`, which flags `[]`, `fetch` and `dig` on `request.params` in an action that declares no
|
|
62
|
+
`params` or `contract`. `AllowedParentClasses` skips subclasses of a validated base action.
|
|
63
|
+
- `Hanami/UnwrappedStep`, which flags a call in an operation's flow to a method that returns `Success` or `Failure`
|
|
64
|
+
but isn't passed to `step`, since dry-operation drops the `Failure`. `FlowMethods` names the flow methods, `call`
|
|
65
|
+
by default, and the cop reads `operate_on` too.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- `Hanami/SliceExports` no longer warns that it does not support `AllowedExports` when a config sets it.
|
|
70
|
+
|
|
10
71
|
## [v0.1.0] - 2026-10-07
|
|
11
72
|
|
|
12
73
|
### Added
|
|
@@ -16,5 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
77
|
`ParentClasses` names the superclasses that mark a class as a slice, `Hanami::Slice` by default.
|
|
17
78
|
`RequireExport: false` lets a slice leave out `export`.
|
|
18
79
|
|
|
19
|
-
[Unreleased]: https://github.com/aaronmallen/rubocop-hanami/compare/0.1
|
|
80
|
+
[Unreleased]: https://github.com/aaronmallen/rubocop-hanami/compare/0.2.1...HEAD
|
|
81
|
+
[v0.2.1]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.2.1
|
|
82
|
+
[v0.2.0]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.2.0
|
|
20
83
|
[v0.1.0]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.1.0
|
data/config/default.yml
CHANGED
|
@@ -5,7 +5,142 @@ 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
|
+
AllowedNames: []
|
|
79
|
+
|
|
80
|
+
Hanami/EnvAccess:
|
|
81
|
+
Description: Checks for `ENV` outside settings and providers.
|
|
82
|
+
Enabled: true
|
|
83
|
+
VersionAdded: "0.2.0"
|
|
84
|
+
Include:
|
|
85
|
+
- "**/*.rb"
|
|
86
|
+
Exclude:
|
|
87
|
+
- "bin/**/*"
|
|
88
|
+
- "config.ru"
|
|
89
|
+
- "config/app.rb"
|
|
90
|
+
- "config/providers/**/*"
|
|
91
|
+
- "config/puma.rb"
|
|
92
|
+
- "config/settings.rb"
|
|
93
|
+
- "db/**/*"
|
|
94
|
+
- "Rakefile"
|
|
95
|
+
- "slices/*/config/providers/**/*"
|
|
96
|
+
- "slices/*/config/settings.rb"
|
|
97
|
+
- "spec/**/*"
|
|
98
|
+
|
|
99
|
+
Hanami/ActionCallOverride:
|
|
100
|
+
Description: Checks for `def call` in an action, where `handle` belongs.
|
|
101
|
+
Enabled: true
|
|
102
|
+
VersionAdded: "0.2.0"
|
|
103
|
+
Include:
|
|
104
|
+
- "app/**/*.rb"
|
|
105
|
+
- "slices/**/*.rb"
|
|
106
|
+
ParentClasses:
|
|
107
|
+
- Hanami::Action
|
|
108
|
+
|
|
109
|
+
Hanami/UnvalidatedParams:
|
|
110
|
+
Description: Checks for `request.params` reads in an action with no `params` or `contract`.
|
|
111
|
+
Enabled: true
|
|
112
|
+
VersionAdded: "0.2.0"
|
|
113
|
+
Include:
|
|
114
|
+
- "**/actions/**/*.rb"
|
|
115
|
+
AllowedParentClasses: []
|
|
116
|
+
|
|
117
|
+
Hanami/ProviderTopLevelRequire:
|
|
118
|
+
Description: Checks for `require` in a provider outside `prepare` and `start`.
|
|
119
|
+
Enabled: true
|
|
120
|
+
VersionAdded: "0.2.0"
|
|
121
|
+
Include:
|
|
122
|
+
- "**/config/providers/*.rb"
|
|
123
|
+
- "**/slices/*/config/providers/*.rb"
|
|
124
|
+
|
|
125
|
+
Hanami/UnwrappedStep:
|
|
126
|
+
Description: Checks for calls in an operation's flow that return a Result but skip `step`.
|
|
127
|
+
Enabled: true
|
|
128
|
+
VersionAdded: "0.2.0"
|
|
129
|
+
Include:
|
|
130
|
+
- "app/**/*.rb"
|
|
131
|
+
- "slices/**/*.rb"
|
|
132
|
+
- "lib/**/*.rb"
|
|
133
|
+
FlowMethods:
|
|
134
|
+
- call
|
|
135
|
+
|
|
136
|
+
Hanami/SliceNamespace:
|
|
137
|
+
Description: Checks that files in a slice open the slice's namespace.
|
|
138
|
+
Enabled: true
|
|
139
|
+
VersionAdded: "0.2.0"
|
|
140
|
+
Include:
|
|
141
|
+
- "slices/**/*.rb"
|
|
142
|
+
Exclude:
|
|
143
|
+
- "slices/*/config/routes.rb"
|
|
144
|
+
- "slices/*/config/settings.rb"
|
|
145
|
+
- "slices/*/db/**/*"
|
|
146
|
+
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,107 @@
|
|
|
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 the way Hanami does, word by word, so `admin_panel`
|
|
12
|
+
# gives `AdminPanel`, and checks only the first segment of each name. It reads the acronyms in
|
|
13
|
+
# `config/app.rb`, so `inflections.acronym "TMAS"` turns `tmas_gate_counts` into `TMASGateCounts`.
|
|
14
|
+
# `Inflections` adds to them, keyed by word or by whole slice name. For nested slices, the
|
|
15
|
+
# innermost one counts.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# # slices/admin/actions/users/index.rb
|
|
19
|
+
#
|
|
20
|
+
# # bad
|
|
21
|
+
# module Backoffice
|
|
22
|
+
# module Actions
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good
|
|
27
|
+
# module Admin
|
|
28
|
+
# module Actions
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example Inflections: { api: API }
|
|
33
|
+
# # slices/api/action.rb
|
|
34
|
+
#
|
|
35
|
+
# # good
|
|
36
|
+
# class API::Action < MyApp::Action
|
|
37
|
+
# end
|
|
38
|
+
class SliceNamespace < Base
|
|
39
|
+
MSG = "Files in `slices/%<slice>s` belong in the `%<namespace>s` namespace."
|
|
40
|
+
SLICE = %r{(?:\A|/)slices/([^/]+)(?=/)}
|
|
41
|
+
|
|
42
|
+
def_node_search :acronyms, "(send _ :acronym $...)"
|
|
43
|
+
|
|
44
|
+
# Lets RuboCop's result cache drop stale results when the app's inflections change.
|
|
45
|
+
def external_dependency_checksum
|
|
46
|
+
path = app_config_path
|
|
47
|
+
Digest::SHA1.hexdigest(File.read(path)) if File.file?(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def on_new_investigation
|
|
51
|
+
slice = slice_name
|
|
52
|
+
return unless slice && processed_source.ast
|
|
53
|
+
|
|
54
|
+
namespace = namespace_for(slice)
|
|
55
|
+
top_level_definitions.each do |definition|
|
|
56
|
+
next if root_name(definition.identifier) == namespace
|
|
57
|
+
|
|
58
|
+
add_offense(definition.identifier, message: format(MSG, slice: slice, namespace: namespace))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Hanami camelizes a slice's name with its parent's inflector. Only the app's is read, so a nested
|
|
65
|
+
# slice ignores acronyms set in its parent slice.
|
|
66
|
+
def app_acronyms
|
|
67
|
+
ast = app_config_ast
|
|
68
|
+
return {} unless ast
|
|
69
|
+
|
|
70
|
+
words = acronyms(ast).to_a.flatten.select { |arg| arg.type?(:str, :sym) }
|
|
71
|
+
words.to_h { |word| [word.value.to_s.downcase, word.value.to_s] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def app_config_ast
|
|
75
|
+
path = app_config_path
|
|
76
|
+
return unless File.file?(path)
|
|
77
|
+
|
|
78
|
+
source = processed_source
|
|
79
|
+
ProcessedSource.from_file(path, source.ruby_version, parser_engine: source.parser_engine).ast
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def app_config_path
|
|
83
|
+
File.join(config.base_dir_for_path_parameters, "config", "app.rb")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def namespace_for(slice)
|
|
87
|
+
rules = app_acronyms.merge(cop_config["Inflections"] || {})
|
|
88
|
+
rules.fetch(slice) { slice.split("_").map { |word| rules.fetch(word) { word.capitalize } }.join }.to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def root_name(const)
|
|
92
|
+
const = const.namespace while const.namespace&.const_type?
|
|
93
|
+
const.short_name.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def slice_name
|
|
97
|
+
processed_source.file_path.to_s.scan(SLICE).last&.first
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def top_level_definitions
|
|
101
|
+
ast = processed_source.ast
|
|
102
|
+
(ast.begin_type? ? ast.children : [ast]).select { |node| node.type?(:module, :class) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
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,
|
|
11
|
+
# `send`, `public_send`, `__send__` or `method` with a literal symbol, or a keyword argument
|
|
12
|
+
# of `initialize` read outside `super`. The cop skips a class
|
|
13
|
+
# that passes `Deps` anything but literal keys, or calls `send` with a name it can't read. It
|
|
14
|
+
# skips modules, since `include Deps` in a module serves whatever class includes it.
|
|
15
|
+
#
|
|
16
|
+
# A dependency also counts as used when a parent class, a subclass at any depth, or a mixin of
|
|
17
|
+
# either reads it. The cop indexes the classes and modules under `app`, `lib` and `slices` to
|
|
18
|
+
# follow them, and stops at constants the project doesn't define, such as `Hanami::Action`. It
|
|
19
|
+
# skips the class if one of them calls `send` with a name it can't read.
|
|
20
|
+
#
|
|
21
|
+
# It still flags a dependency read somewhere it can't follow. Disable it with a comment on that
|
|
22
|
+
# key, or name it in `AllowedNames` to never flag it.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad
|
|
26
|
+
# class Operations::CreateUser
|
|
27
|
+
# include Deps["repos.user_repo", "mailers.welcome"]
|
|
28
|
+
#
|
|
29
|
+
# def call(input)
|
|
30
|
+
# user_repo.create(input)
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# # good
|
|
35
|
+
# class Operations::CreateUser
|
|
36
|
+
# include Deps["repos.user_repo"]
|
|
37
|
+
#
|
|
38
|
+
# def call(input)
|
|
39
|
+
# user_repo.create(input)
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# @example AllowedNames: [contract]
|
|
44
|
+
# # good
|
|
45
|
+
# class Operations::SaveTask < MyApp::Operation
|
|
46
|
+
# include Deps[contract: "contracts.task"]
|
|
47
|
+
# end
|
|
48
|
+
class UnusedDeps < Base
|
|
49
|
+
include DepsKeys
|
|
50
|
+
|
|
51
|
+
MSG = "`%<name>s` from `Deps[\"%<key>s\"]` is never used."
|
|
52
|
+
|
|
53
|
+
def_node_matcher :dynamic_send?, "(send _ {:send :public_send :__send__} !sym ...)"
|
|
54
|
+
def_node_search :initializers, "(def :initialize ...)"
|
|
55
|
+
def_node_matcher :literal_send, "(send _ {:send :public_send :__send__ :method} (sym $_) ...)"
|
|
56
|
+
|
|
57
|
+
# Lets RuboCop's result cache drop stale results when a parent class or mixin changes.
|
|
58
|
+
def external_dependency_checksum
|
|
59
|
+
RuboCop::Hanami::ClassIndex.checksum(config.base_dir_for_path_parameters)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_class(node)
|
|
63
|
+
arguments = deps_arguments_in(node)
|
|
64
|
+
return unless checkable?(node, arguments)
|
|
65
|
+
|
|
66
|
+
unused = unused_keys(arguments, used_names(node) + Array(cop_config["AllowedNames"]))
|
|
67
|
+
return if unused.empty?
|
|
68
|
+
|
|
69
|
+
related = related_reads(node)
|
|
70
|
+
return unless related
|
|
71
|
+
|
|
72
|
+
unused.each do |key_node, name|
|
|
73
|
+
add_offense(key_node, message: format(MSG, name: name, key: key_node.value)) unless related.include?(name)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def checkable?(node, arguments)
|
|
80
|
+
return false if arguments.empty? || !arguments.all? { |argument| literal?(argument) }
|
|
81
|
+
|
|
82
|
+
node.each_descendant(:send).none? { |send| dynamic_send?(send) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def class_index
|
|
86
|
+
RuboCop::Hanami::ClassIndex.for(config.base_dir_for_path_parameters, processed_source) do |node|
|
|
87
|
+
used_names(node) if node.each_descendant(:send).none? { |send| dynamic_send?(send) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def deps_arguments_in(node)
|
|
92
|
+
body = node.body
|
|
93
|
+
children = body&.begin_type? ? body.children : [body]
|
|
94
|
+
children.compact.filter_map { |child| deps_arguments(child) }.flatten
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Passing a keyword on to `super` doesn't count, since the kwargs strategy needs that to inject it.
|
|
98
|
+
def keyword_reads(method)
|
|
99
|
+
keywords = method.arguments.select { |argument| argument.type?(:kwarg, :kwoptarg) }.map(&:name)
|
|
100
|
+
reads = method.each_descendant(:lvar).select { |lvar| lvar.each_ancestor(:super).none? }.map(&:name)
|
|
101
|
+
(keywords & reads).map(&:to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def literal?(argument)
|
|
105
|
+
argument.str_type? || (argument.hash_type? && argument.pairs.all? { |pair| literal_alias?(pair) })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_name(read)
|
|
109
|
+
return read.children.first.to_s.delete_prefix("@") if read.ivar_type?
|
|
110
|
+
|
|
111
|
+
literal_send(read)&.to_s || (read.method_name.to_s if read.receiver.nil? || read.receiver.self_type?)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The names every parent, child and mixin of `node` reads, or nil when one of them reads names
|
|
115
|
+
# the cop can't see.
|
|
116
|
+
def related_reads(node)
|
|
117
|
+
index = class_index
|
|
118
|
+
reads = index.related(index.definition(node, nil)).map(&:reads)
|
|
119
|
+
reads.sum(Set[]) if reads.all?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unused_keys(arguments, used)
|
|
123
|
+
unused = []
|
|
124
|
+
each_deps_key(arguments) do |key_node, name|
|
|
125
|
+
unused << [key_node, name] unless name.nil? || used.include?(name)
|
|
126
|
+
end
|
|
127
|
+
unused
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def used_names(node)
|
|
131
|
+
reads = node.each_descendant(:send, :ivar).to_set { |read| read_name(read) }
|
|
132
|
+
reads.merge(initializers(node).flat_map { |method| keyword_reads(method) })
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
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
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Hanami
|
|
5
|
+
# Indexes the classes and modules defined under a project's `app`, `lib` and `slices`, so a cop can
|
|
6
|
+
# follow a class to the parents and mixins it inherits from, and to the classes that inherit from
|
|
7
|
+
# it, across files.
|
|
8
|
+
class ClassIndex
|
|
9
|
+
extend NodePattern::Macros
|
|
10
|
+
|
|
11
|
+
GLOB = "{app,lib,slices}/**/*.rb"
|
|
12
|
+
# ponytail: rescans at most once a second, so a language server may lint against a one second old
|
|
13
|
+
# project. Watch files instead if that ever matters.
|
|
14
|
+
TTL = 1
|
|
15
|
+
|
|
16
|
+
# `parents` holds each superclass or mixin as written, with the lexical scopes to resolve it in.
|
|
17
|
+
# `reads` is the set of names the body reads, or nil when it reads names the cop can't see.
|
|
18
|
+
Definition = Struct.new(:name, :parents, :reads)
|
|
19
|
+
|
|
20
|
+
def_node_matcher :mixin_arguments, "(send nil? {:include :prepend} $...)"
|
|
21
|
+
|
|
22
|
+
@indexes = {}
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Changes whenever a file under the indexed roots is added, removed or touched.
|
|
26
|
+
def checksum(root)
|
|
27
|
+
Digest::SHA1.hexdigest(Dir.glob(GLOB, base: root).sort.map { |path| "#{path}#{mtime(root, path)}" }.join)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# One index per project root, shared by every file in the run. `reads` takes a class or module
|
|
31
|
+
# node and returns its Definition#reads.
|
|
32
|
+
def for(root, processed_source, &reads)
|
|
33
|
+
index = @indexes[root] ||= new(root, processed_source, reads)
|
|
34
|
+
index.refresh
|
|
35
|
+
index
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mtime(root, path)
|
|
39
|
+
File.mtime(File.join(root, path)).to_f
|
|
40
|
+
rescue Errno::ENOENT
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The constant's full name, as Module#name would give it.
|
|
45
|
+
def name_of(node)
|
|
46
|
+
name = node.identifier.source
|
|
47
|
+
return name.delete_prefix("::") if name.start_with?("::")
|
|
48
|
+
|
|
49
|
+
outer = node.each_ancestor(:class, :module).first
|
|
50
|
+
outer ? "#{name_of(outer)}::#{name}" : name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The full names of the classes and modules around `node`, innermost first.
|
|
54
|
+
def scopes_of(node)
|
|
55
|
+
node.each_ancestor(:class, :module).map { |scope| name_of(scope) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(root, processed_source, reads)
|
|
60
|
+
@root = root
|
|
61
|
+
@ruby_version = processed_source.ruby_version
|
|
62
|
+
@parser_engine = processed_source.parser_engine
|
|
63
|
+
@reads = reads
|
|
64
|
+
@files = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Every definition `definition` inherits from, through superclasses and mixins, that the project
|
|
68
|
+
# defines. A constant from outside the project, such as `Hanami::Action`, ends the walk.
|
|
69
|
+
def ancestors(definition, seen = Set[definition.name])
|
|
70
|
+
definition.parents.flat_map do |constant, scopes|
|
|
71
|
+
name = resolve(constant, scopes, definition.name)
|
|
72
|
+
next [] unless name && seen.add?(name)
|
|
73
|
+
|
|
74
|
+
@definitions[name].flat_map { |parent| [parent, *ancestors(parent, seen)] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Every definition that inherits from `name`, at any depth.
|
|
79
|
+
def descendants(name, seen = Set[name])
|
|
80
|
+
@children.fetch(name, []).flat_map do |child|
|
|
81
|
+
seen.add?(child.name) ? [child, *descendants(child.name, seen)] : []
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def definition(node, reads = @reads.call(node))
|
|
86
|
+
name = self.class.name_of(node)
|
|
87
|
+
scopes = self.class.scopes_of(node)
|
|
88
|
+
parents = mixins(node).map { |constant| [constant.source, [name, *scopes]] }
|
|
89
|
+
parents.unshift([node.parent_class.source, scopes]) if node.class_type? && node.parent_class&.const_type?
|
|
90
|
+
Definition.new(name, parents, reads)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parses only the files added or touched since the last scan.
|
|
94
|
+
def refresh
|
|
95
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
return if @checked_at && now - @checked_at < TTL
|
|
97
|
+
|
|
98
|
+
@checked_at = now
|
|
99
|
+
@files = Dir.glob(GLOB, base: @root).to_h { |path| [path, file_entry(path)] }
|
|
100
|
+
definitions = @files.values.flat_map(&:last)
|
|
101
|
+
@definitions = definitions.group_by(&:name)
|
|
102
|
+
@children = children_of(definitions)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Everything whose reads count for `definition`: its ancestors, its descendants, and the ancestors
|
|
106
|
+
# of those descendants, such as a mixin a subclass includes.
|
|
107
|
+
def related(definition)
|
|
108
|
+
family = [definition, *descendants(definition.name)]
|
|
109
|
+
(family.drop(1) + family.flat_map { |member| ancestors(member) }).uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def children_of(definitions)
|
|
115
|
+
children = Hash.new { |hash, name| hash[name] = [] }
|
|
116
|
+
definitions.each do |child|
|
|
117
|
+
child.parents.each do |constant, scopes|
|
|
118
|
+
name = resolve(constant, scopes, child.name)
|
|
119
|
+
children[name] << child if name
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
children
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def file_entry(path)
|
|
126
|
+
mtime = self.class.mtime(@root, path)
|
|
127
|
+
cached = @files[path]
|
|
128
|
+
cached&.first == mtime ? cached : [mtime, parse(path)]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def mixins(node)
|
|
132
|
+
body = node.body
|
|
133
|
+
children = body&.begin_type? ? body.children : [body]
|
|
134
|
+
children.compact.flat_map { |child| mixin_arguments(child) || [] }.select(&:const_type?)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse(path)
|
|
138
|
+
ast = ProcessedSource.from_file(File.join(@root, path), @ruby_version, parser_engine: @parser_engine).ast
|
|
139
|
+
ast ? ast.each_node(:class, :module).map { |node| definition(node) } : []
|
|
140
|
+
rescue Errno::ENOENT
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Looks the constant up the way Ruby would, from the innermost scope out, skipping the class that
|
|
145
|
+
# names it so `class Operation < Operation` finds the outer one.
|
|
146
|
+
def resolve(constant, scopes, skip)
|
|
147
|
+
candidates = [*scopes.map { |scope| "#{scope}::#{constant}" }, constant]
|
|
148
|
+
candidates = [constant.delete_prefix("::")] if constant.start_with?("::")
|
|
149
|
+
candidates.find { |name| name != skip && @definitions.key?(name) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/rubocop-hanami.rb
CHANGED
|
@@ -2,5 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require "rubocop"
|
|
4
4
|
|
|
5
|
+
require_relative "rubocop/hanami/class_index"
|
|
5
6
|
require_relative "rubocop/hanami/plugin"
|
|
7
|
+
require_relative "rubocop/cop/hanami/mixin/deps_keys"
|
|
8
|
+
|
|
9
|
+
require_relative "rubocop/cop/hanami/action_call_override"
|
|
10
|
+
require_relative "rubocop/cop/hanami/app_reference_in_slice"
|
|
11
|
+
require_relative "rubocop/cop/hanami/container_lookup"
|
|
12
|
+
require_relative "rubocop/cop/hanami/env_access"
|
|
13
|
+
require_relative "rubocop/cop/hanami/persistence_in_action"
|
|
14
|
+
require_relative "rubocop/cop/hanami/provider_top_level_require"
|
|
15
|
+
require_relative "rubocop/cop/hanami/relation_outside_repo"
|
|
6
16
|
require_relative "rubocop/cop/hanami/slice_exports"
|
|
17
|
+
require_relative "rubocop/cop/hanami/slice_namespace"
|
|
18
|
+
require_relative "rubocop/cop/hanami/unused_deps"
|
|
19
|
+
require_relative "rubocop/cop/hanami/unvalidated_params"
|
|
20
|
+
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
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aaron Allen
|
|
@@ -48,7 +48,20 @@ 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
|
|
64
|
+
- lib/rubocop/hanami/class_index.rb
|
|
52
65
|
- lib/rubocop/hanami/plugin.rb
|
|
53
66
|
homepage: https://github.com/aaronmallen/rubocop-hanami
|
|
54
67
|
licenses:
|