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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d0cdb245fcc1d369d11881b9ebc6064cc09dfe3acf0267fd2329fe3cd0f662c
4
- data.tar.gz: 9649a91707ea8b596f96edb4e6c636a3ed6fe1b323a1533c1395c032178b4116
3
+ metadata.gz: b66d94028f3350e9dc3966897abca014e9ccad980d431858e664b43421293cd2
4
+ data.tar.gz: 461f52c846ff69d42d92f1ba5e9f88259b88e32607f0f61130e0d650e359e95d
5
5
  SHA512:
6
- metadata.gz: b0c2d75c9f70db90f82d4bb707b315a178972c6a761f681264f3b6739cffe155c80e76c74e253dcd8bd5ccd6e35e878410eb1b3989c641e5c9125493506da7b1
7
- data.tar.gz: 38031749617b42962b73ef90165d04ca176b5c2160265c3207a2a847f1314d70dc3886bda2e4feb865d9d9fed473635601e9b96279041c761345b029c5023770
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.0...HEAD
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.key?("AllowedExports") && !matches?("AllowedExports", key)
87
+ MSG_NOT_ALLOWED if Array(cop_config["AllowedExports"]).any? && !matches?("AllowedExports", key)
88
88
  end
89
89
 
90
90
  def slice_class?(node)
@@ -0,0 +1,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
@@ -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.0
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: