rubocop-hanami 0.2.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: 7f6976191e56400636d7d6b59ab3be6b59fe8bcdbb83385412b7d8c49815c022
4
- data.tar.gz: 8d27ebaacb50254b176b175c85c2170dc44e663a4e3a15858cc4d03ecda0c86a
3
+ metadata.gz: b66d94028f3350e9dc3966897abca014e9ccad980d431858e664b43421293cd2
4
+ data.tar.gz: 461f52c846ff69d42d92f1ba5e9f88259b88e32607f0f61130e0d650e359e95d
5
5
  SHA512:
6
- metadata.gz: 9c7df42e38e04d36610528c38c52ca955d39109b0453e75f0222ba29b6d9631f3a001e16c0da165d89e57662137cdf94e1be8a54d11282976b179a045d9762c0
7
- data.tar.gz: 0b7d37ba2fc067c567e9f3648080159cf7f694c682286213cdce6c589a85ad4d6078b0a6b354a1fec1a19f0faa9f3e7438214895160de67dc9ebe5fa7f74eae7
6
+ metadata.gz: c675f410f6986ad1f8d8cce76f009ed5bdec6b33311d1ce4d49c2f0086d301f17cd4dd1f0d93e23406edad78d90ea5429f08bb50927fc2c358e94cda0a8c9e05
7
+ data.tar.gz: 83e3270654f1717d865a996e5257becb33edc829cb5ce1d8bd0722762586996465683d6d0793c0bb9246f30e0deb9ec21a79171150d1698346e3fd151eba4161
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ 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
+
10
34
  ## [v0.2.0] - 2026-10-07
11
35
 
12
36
  ### Added
@@ -53,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
53
77
  `ParentClasses` names the superclasses that mark a class as a slice, `Hanami::Slice` by default.
54
78
  `RequireExport: false` lets a slice leave out `export`.
55
79
 
56
- [Unreleased]: https://github.com/aaronmallen/rubocop-hanami/compare/0.2.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
57
82
  [v0.2.0]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.2.0
58
83
  [v0.1.0]: https://github.com/aaronmallen/rubocop-hanami/releases/tag/0.1.0
data/config/default.yml CHANGED
@@ -75,6 +75,7 @@ Hanami/UnusedDeps:
75
75
  - "app/**/*.rb"
76
76
  - "slices/**/*.rb"
77
77
  - "lib/**/*.rb"
78
+ AllowedNames: []
78
79
 
79
80
  Hanami/EnvAccess:
80
81
  Description: Checks for `ENV` outside settings and providers.
@@ -8,9 +8,11 @@ module RuboCop
8
8
  # `Admin::Actions::Users::Index`. A mismatch raises at load time, often far from the file at
9
9
  # fault.
10
10
  #
11
- # The cop camelizes the slice's directory, so `admin_panel` gives `AdminPanel`, and checks only
12
- # the first segment of each name. Name slices that don't camelize plainly in `Inflections`. For
13
- # nested slices, the innermost one counts.
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.
14
16
  #
15
17
  # @example
16
18
  # # slices/admin/actions/users/index.rb
@@ -37,6 +39,14 @@ module RuboCop
37
39
  MSG = "Files in `slices/%<slice>s` belong in the `%<namespace>s` namespace."
38
40
  SLICE = %r{(?:\A|/)slices/([^/]+)(?=/)}
39
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
+
40
50
  def on_new_investigation
41
51
  slice = slice_name
42
52
  return unless slice && processed_source.ast
@@ -51,8 +61,31 @@ module RuboCop
51
61
 
52
62
  private
53
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
+
54
86
  def namespace_for(slice)
55
- (cop_config["Inflections"] || {}).fetch(slice) { slice.split("_").map(&:capitalize).join }.to_s
87
+ rules = app_acronyms.merge(cop_config["Inflections"] || {})
88
+ rules.fetch(slice) { slice.split("_").map { |word| rules.fetch(word) { word.capitalize } }.join }.to_s
56
89
  end
57
90
 
58
91
  def root_name(const)
@@ -7,13 +7,19 @@ module RuboCop
7
7
  # dependency still gets resolved, still boots its provider and still shows up as a constructor
8
8
  # argument.
9
9
  #
10
- # A read is a bare call or a call on `self`, the instance variable the kwargs strategy sets, or
11
- # `send`, `public_send`, `__send__` or `method` with a literal symbol. The cop skips a class
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
12
13
  # that passes `Deps` anything but literal keys, or calls `send` with a name it can't read. It
13
14
  # skips modules, since `include Deps` in a module serves whatever class includes it.
14
15
  #
15
- # The cop sees one file, so it flags a dependency that only a subclass reads. Disable it with a
16
- # comment on that key.
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.
17
23
  #
18
24
  # @example
19
25
  # # bad
@@ -33,23 +39,38 @@ module RuboCop
33
39
  # user_repo.create(input)
34
40
  # end
35
41
  # end
42
+ #
43
+ # @example AllowedNames: [contract]
44
+ # # good
45
+ # class Operations::SaveTask < MyApp::Operation
46
+ # include Deps[contract: "contracts.task"]
47
+ # end
36
48
  class UnusedDeps < Base
37
49
  include DepsKeys
38
50
 
39
51
  MSG = "`%<name>s` from `Deps[\"%<key>s\"]` is never used."
40
52
 
41
53
  def_node_matcher :dynamic_send?, "(send _ {:send :public_send :__send__} !sym ...)"
54
+ def_node_search :initializers, "(def :initialize ...)"
42
55
  def_node_matcher :literal_send, "(send _ {:send :public_send :__send__ :method} (sym $_) ...)"
43
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
+
44
62
  def on_class(node)
45
63
  arguments = deps_arguments_in(node)
46
64
  return unless checkable?(node, arguments)
47
65
 
48
- used = used_names(node)
49
- each_deps_key(arguments) do |key_node, name|
50
- next if name.nil? || used.include?(name)
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
51
71
 
52
- add_offense(key_node, message: format(MSG, name: name, key: key_node.value))
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)
53
74
  end
54
75
  end
55
76
 
@@ -61,22 +82,54 @@ module RuboCop
61
82
  node.each_descendant(:send).none? { |send| dynamic_send?(send) }
62
83
  end
63
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
+
64
91
  def deps_arguments_in(node)
65
92
  body = node.body
66
93
  children = body&.begin_type? ? body.children : [body]
67
94
  children.compact.filter_map { |child| deps_arguments(child) }.flatten
68
95
  end
69
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
+
70
104
  def literal?(argument)
71
105
  argument.str_type? || (argument.hash_type? && argument.pairs.all? { |pair| literal_alias?(pair) })
72
106
  end
73
107
 
74
- def used_names(node)
75
- node.each_descendant(:send, :ivar).to_set do |read|
76
- next read.children.first.to_s.delete_prefix("@") if read.ivar_type?
108
+ def read_name(read)
109
+ return read.children.first.to_s.delete_prefix("@") if read.ivar_type?
77
110
 
78
- literal_send(read)&.to_s || (read.method_name.to_s if read.receiver.nil? || read.receiver.self_type?)
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)
79
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) })
80
133
  end
81
134
  end
82
135
  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,6 +2,7 @@
2
2
 
3
3
  require "rubocop"
4
4
 
5
+ require_relative "rubocop/hanami/class_index"
5
6
  require_relative "rubocop/hanami/plugin"
6
7
  require_relative "rubocop/cop/hanami/mixin/deps_keys"
7
8
 
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Allen
@@ -61,6 +61,7 @@ files:
61
61
  - lib/rubocop/cop/hanami/unused_deps.rb
62
62
  - lib/rubocop/cop/hanami/unvalidated_params.rb
63
63
  - lib/rubocop/cop/hanami/unwrapped_step.rb
64
+ - lib/rubocop/hanami/class_index.rb
64
65
  - lib/rubocop/hanami/plugin.rb
65
66
  homepage: https://github.com/aaronmallen/rubocop-hanami
66
67
  licenses: