rubocop-minify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '049d556f390def41fa201dedd271537d50e8cc52089a88390265099294b98d06'
4
+ data.tar.gz: d5456061700c24de435649ae2a13887c30b8390f50de47667a560e44c56c4873
5
+ SHA512:
6
+ metadata.gz: 7587a7837c4f11f0edc58d071367b039cd0c51ec9f54e4f1e4dbc84d23dcf7881228bc43714757663f08a7e0e87fec85e77d2c4ac64e6182a683957dd1344da7
7
+ data.tar.gz: c20b028278b3b49797bbaeaee9b460a5df1105dbfaad073dbda4fc4298bb3746d42da6847fce9ba1b46d0da82aa0b2da0d69e5ac3d827fe906a22ae65b50b0c6
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-18)
4
+
5
+ First release.
6
+
7
+ - `Minify/AttributeMethods`: rewrites calls to the attribute methods strict mode removes, from the app's manifest.
8
+ - `minify-erb`: the same rewrite in ERB templates (Herb), with `--fix`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sampo Kuokkanen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # rubocop-minify
2
+
3
+ RuboCop cops, and an ERB fixer, that rewrite code relying on what
4
+ [rails-minify](../rails-minify) removes from Rails. Part of rails-minify.
5
+
6
+ activerecord-minimizer's strict mode stops generating 19 of the 21 attribute
7
+ methods per column (`name?`, `name_changed?`, `saved_change_to_name?`, ...).
8
+ `Minify/AttributeMethods` rewrites the calls into what they stood for:
9
+
10
+ ```ruby
11
+ venue.featured? # => venue.featured.present?
12
+ return unless venue.featured? # => return unless venue.featured
13
+ venue.name? # => venue.name.present?
14
+ venue.rank? # => venue.query_attribute(:rank)
15
+ venue.name_changed?(to: "x") # => venue.attribute_changed?(:name, to: "x")
16
+ before_save :geocode, if: :address_changed?
17
+ # => if: -> { attribute_changed?(:address) }
18
+ ```
19
+
20
+ `name?` follows the column's type: a boolean or date column reads as itself
21
+ where only truthiness matters (conditions, `!`, `&&`/`||` inside them) and as
22
+ `.present?` elsewhere; strings use `.present?`; numbers, enums and decorated
23
+ columns use `query_attribute`. Arguments, `&.` and nesting are kept.
24
+
25
+ ## Setup
26
+
27
+ ```ruby
28
+ # Gemfile
29
+ group :development do
30
+ gem "rubocop-minify", require: false
31
+ end
32
+ ```
33
+
34
+ ```yaml
35
+ # .rubocop.yml
36
+ plugins:
37
+ - rubocop-minify
38
+ ```
39
+
40
+ The cop reads activerecord-minimizer's manifest, `config/ar_minimizer.json`
41
+ (`bin/rails ar_minimizer:manifest`): which names strict mode removes, from
42
+ which models, for which attribute and column type. Without a manifest it does
43
+ nothing.
44
+
45
+ ```sh
46
+ bin/rubocop --only Minify/AttributeMethods -a # Ruby: app, lib, test, db, ...
47
+ bundle exec minify-erb --fix app # templates
48
+ ```
49
+
50
+ RuboCop doesn't read ERB, so `minify-erb` does the same rewrite on the Ruby
51
+ inside each tag (parsed with Herb). Without `--fix` it lists the calls; it
52
+ exits non-zero while any are left, for CI.
53
+
54
+ ## What it won't rewrite
55
+
56
+ Receivers aren't typed, so the cop only rewrites what can't mean something
57
+ else, and reports the rest for you to fix by hand or add to the keep list
58
+ (`config/ar_minimizer_keep.txt`):
59
+
60
+ * Names the app also defines (`def featured?` in a presenter, `delegate`,
61
+ `attr_reader`, `alias_method`, ...), scanned from `app/` and `lib/`
62
+ (`DefinitionPaths`).
63
+ * Names another model defines some other way, such as an enum predicate
64
+ called like another model's column (the manifest's `shadows`).
65
+ * Names Ruby or Active Record define everywhere (`key?`, `empty?`,
66
+ `readonly?`), and calls with arguments to methods that take none.
67
+ * Calls without a receiver outside a model, or in a module included by
68
+ models of which only some have the column. In templates, `self` is a view,
69
+ so receiver-less calls are never records.
70
+ * Private proxies (`name_will_change!` → `attribute_will_change!(:name)`) on
71
+ another object; on `self` they're rewritten.
72
+ * Method names passed to `send`, `try` and `delegate`.
73
+
74
+ ## Configuration
75
+
76
+ ```yaml
77
+ Minify/AttributeMethods:
78
+ Manifest: config/ar_minimizer.json
79
+ DefinitionPaths:
80
+ - "app/**/*.rb"
81
+ - "lib/**/*.rb"
82
+ ```
83
+
84
+ ## On golf-buddy
85
+
86
+ 191 models: 130 calls in Ruby (app, lib, test, db) and 21 templates rewritten
87
+ automatically; 11 names left for the keep list, all of them names some other
88
+ class defines too. The test suite gives the same results in strict mode
89
+ afterwards.
@@ -0,0 +1,14 @@
1
+ Minify/AttributeMethods:
2
+ Description: >-
3
+ Rewrites calls to the attribute methods activerecord-minimizer's strict
4
+ mode removes (name?, name_changed?, saved_change_to_name?, ...) into the
5
+ calls they stood for.
6
+ Enabled: true
7
+ VersionAdded: "0.1"
8
+ # Written by `bin/rails ar_minimizer:manifest`: which names are removed, and for which attribute.
9
+ Manifest: config/ar_minimizer.json
10
+ # Where to look for hand-written methods sharing a removed name (a PORO's
11
+ # featured?): calls of those names are reported but never rewritten.
12
+ DefinitionPaths:
13
+ - "app/**/*.rb"
14
+ - "lib/**/*.rb"
data/exe/minify-erb ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Rewrites (with --fix) or lists calls to removed attribute methods in ERB
5
+ # templates, the part of the app RuboCop doesn't read.
6
+ #
7
+ # minify-erb [--fix] [--manifest config/ar_minimizer.json] [paths...]
8
+ require "optparse"
9
+ require "rubocop_minify"
10
+
11
+ fix = false
12
+ manifest = "config/ar_minimizer.json"
13
+ OptionParser.new do |o|
14
+ o.banner = "usage: minify-erb [--fix] [--manifest PATH] [paths...]"
15
+ o.on("--fix", "rewrite the templates in place") { fix = true }
16
+ o.on("--manifest PATH") { manifest = it }
17
+ end.parse!
18
+
19
+ root = Dir.pwd
20
+ rewrite = RubocopMinify.rewrite_for(root, manifest: manifest, definition_paths: ["app/**/*.rb", "lib/**/*.rb"], views: true)
21
+ abort "minify-erb: no #{manifest}; run bin/rails ar_minimizer:manifest" unless rewrite
22
+
23
+ paths = (ARGV.empty? ? ["app"] : ARGV).flat_map { File.directory?(it) ? Dir.glob("#{it}/**/*.erb") : [it] }.sort
24
+ rewritten = 0
25
+ left = 0
26
+ paths.each do |path|
27
+ template = File.read(path)
28
+ line_of = ->(offset) { template.byteslice(0, offset).count("\n") + 1 }
29
+ if fix
30
+ fixed, manual = RubocopMinify::ERB.fix(template, rewrite)
31
+ if fixed != template
32
+ File.write(path, fixed)
33
+ rewritten += 1
34
+ puts "rewrote #{path}"
35
+ end
36
+ template = fixed
37
+ line_of = ->(offset) { template.byteslice(0, offset).count("\n") + 1 }
38
+ manual.each { puts "#{path}:#{line_of.(it.from)}: #{it.message}" }
39
+ left += manual.size
40
+ else
41
+ RubocopMinify::ERB.edits(template, rewrite).each do |edit|
42
+ puts "#{path}:#{line_of.(edit.from)}: #{edit.message}"
43
+ left += 1
44
+ end
45
+ end
46
+ end
47
+ puts(fix ? "#{rewritten} templates rewritten, #{left} calls to fix by hand" : "#{left} calls")
48
+ exit(left.zero? ? 0 : 1)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Minify
6
+ # Calls to the attribute methods activerecord-minimizer's strict mode
7
+ # removes, rewritten into what they stood for. See RubocopMinify::Rewrite.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # venue.featured?
12
+ # venue.name_changed?
13
+ # before_save :geocode, if: :address_changed?
14
+ #
15
+ # # good
16
+ # venue.featured.present?
17
+ # venue.attribute_changed?(:name)
18
+ # before_save :geocode, if: -> { attribute_changed?(:address) }
19
+ class AttributeMethods < Base
20
+ extend AutoCorrector
21
+
22
+ def on_new_investigation
23
+ rewrite = RubocopMinify.rewrite_for(config.base_dir_for_path_parameters,
24
+ manifest: cop_config.fetch("Manifest", "config/ar_minimizer.json"),
25
+ definition_paths: Array(cop_config.fetch("DefinitionPaths", ["app/**/*.rb", "lib/**/*.rb"])))
26
+ return unless rewrite
27
+
28
+ source = processed_source.raw_source
29
+ rewrite.edits(source).each do |edit|
30
+ range = range_between(source.byteslice(0, edit.from).length, source.byteslice(0, edit.to).length)
31
+ if edit.replacement
32
+ add_offense(range, message: edit.message) { it.replace(range, edit.replacement) }
33
+ else
34
+ add_offense(range, message: edit.message)
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def range_between(from, to) = Parser::Source::Range.new(processed_source.buffer, from, to)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lint_roller"
4
+
5
+ module RuboCop
6
+ module Minify
7
+ # `plugins: [rubocop-minify]` in .rubocop.yml.
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(name: "rubocop-minify", version: RubocopMinify::VERSION,
11
+ homepage: "https://github.com/sampokuokkanen/rails-minify",
12
+ description: "Rewrites code relying on what rails-minify removes from Rails")
13
+ end
14
+
15
+ def supported?(context) = context.engine == :rubocop
16
+
17
+ def rules(_context)
18
+ LintRoller::Rules.new(type: :path, config_format: :rubocop, value: Pathname.new(__dir__).join("../../../config/default.yml"))
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+ require "rubocop_minify"
5
+ require "rubocop/minify/plugin"
6
+ require "rubocop/cop/minify/attribute_methods"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module RubocopMinify
6
+ # Hand-written methods in the app, by name: { "featured?" => "app/models/poro.rb:12" }.
7
+ # def, define_method, alias, alias_method, attr_*, delegate and def_delegator(s).
8
+ module Definitions
9
+ DEFINERS = %i[define_method alias_method attr_reader attr_writer attr_accessor delegate def_delegator def_delegators].freeze
10
+
11
+ def self.scan(root, patterns)
12
+ found = {}
13
+ patterns.flat_map { Dir.glob(it, base: root) }.sort.each do |path|
14
+ walk(Prism.parse_file(File.join(root, path)).value, path, found)
15
+ end
16
+ found
17
+ end
18
+
19
+ def self.walk(node, path, found)
20
+ case node
21
+ when Prism::DefNode
22
+ found[node.name.to_s] ||= "#{path}:#{node.location.start_line}"
23
+ when Prism::AliasMethodNode
24
+ found[node.new_name.slice.delete_prefix(":")] ||= "#{path}:#{node.location.start_line}" if node.new_name.is_a?(Prism::SymbolNode)
25
+ when Prism::CallNode
26
+ if DEFINERS.include?(node.name)
27
+ symbols = node.arguments&.arguments&.grep(Prism::SymbolNode) || []
28
+ symbols = symbols.first(1) if %i[define_method alias_method].include?(node.name)
29
+ symbols = symbols.drop(1) if node.name == :def_delegators # the accessor comes first
30
+ symbols.each { found[it.unescaped] ||= "#{path}:#{node.location.start_line}" }
31
+ end
32
+ end
33
+ node&.compact_child_nodes&.each { walk(it, path, found) }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+
5
+ module RubocopMinify
6
+ # The same rewrite in ERB templates (RuboCop doesn't read them): Herb finds
7
+ # the Ruby in each tag, Rewrite works on it as code that runs in a view.
8
+ module ERB
9
+ # Rewrite::Edits with offsets into the template.
10
+ def self.edits(template, rewrite)
11
+ found = []
12
+ collect = lambda do |node|
13
+ if node.class.name.start_with?("Herb::AST::ERB") && node.respond_to?(:content) && node.content &&
14
+ !node.class.name.end_with?("CommentNode") && !node.tag_opening&.value.to_s.start_with?("<%#")
15
+ base = node.content.range.from
16
+ rewrite.edits(node.content.value).each do |e|
17
+ found << Rewrite::Edit.new(e.from + base, e.to + base, e.replacement, e.message)
18
+ end
19
+ end
20
+ node.compact_child_nodes.each(&collect)
21
+ end
22
+ collect.(Herb.parse(template).value)
23
+ found
24
+ end
25
+
26
+ # The template with every rewritable call rewritten, and what's left to
27
+ # do by hand.
28
+ def self.fix(template, rewrite)
29
+ 5.times do
30
+ edits = edits(template, rewrite)
31
+ fixed = Rewrite.apply(template, edits)
32
+ return [fixed, edits.reject(&:replacement)] if fixed == template
33
+
34
+ template = fixed
35
+ end
36
+ [template, edits(template, rewrite).reject(&:replacement)]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module RubocopMinify
6
+ # Finds calls to the attribute methods activerecord-minimizer's strict mode
7
+ # removes and what each stood for:
8
+ #
9
+ # venue.featured? -> venue.featured.present? (venue.featured in a condition)
10
+ # venue.name? -> venue.name.present?
11
+ # venue.rank? -> venue.query_attribute(:rank)
12
+ # venue.name_changed?(to: x) -> venue.attribute_changed?(:name, to: x)
13
+ # before_save :x, if: :name_changed? -> if: -> { attribute_changed?(:name) }
14
+ #
15
+ # Which names are removed, for which attribute and with which column type
16
+ # comes from the ar_minimizer manifest (an ArMinimizer::Index). Receivers
17
+ # aren't typed: a name is rewritten wherever it's called on an explicit
18
+ # receiver, unless the app also defines a method of that name somewhere
19
+ # (+defined+), and then it's only reported. Calls without a receiver are
20
+ # only rewritten inside models and the app modules they include.
21
+ class Rewrite
22
+ # Byte offsets into the source. replacement nil: reported, not rewritten.
23
+ Edit = Data.define(:from, :to, :replacement, :message)
24
+
25
+ SEND = %i[send public_send __send__ try try!].freeze
26
+ # A removed name that core classes also define (key?, empty?, ...) is
27
+ # far more often one of those.
28
+ CORE = [Object, Kernel, Comparable, Enumerable, Hash, Array, String, Symbol, Integer, Float, NilClass, Range, Set].freeze
29
+ CALLBACK_OPTIONS = %i[if unless].freeze
30
+
31
+ # defined: { method name => "path:line" } of hand-written methods.
32
+ # views: the source is template code (self is a view, never a record).
33
+ def initialize(index, defined: {}, views: false)
34
+ @index = index
35
+ @defined = defined
36
+ @views = views
37
+ end
38
+
39
+ def edits(source)
40
+ root = Prism.parse(source).value
41
+ @edits = []
42
+ @nesting = []
43
+ @class_level = 0
44
+ @locals = locals(root)
45
+ visit(root, false)
46
+ @edits.sort_by(&:from)
47
+ end
48
+
49
+ # Applies the rewritable edits that don't overlap (nested ones are left
50
+ # for the next pass), from the end so offsets stay valid.
51
+ def self.apply(source, edits)
52
+ out = source.b
53
+ last = nil
54
+ edits.select(&:replacement).sort_by { -it.from }.each do |edit|
55
+ next if last && edit.to > last
56
+
57
+ out.bytesplice(edit.from, edit.to - edit.from, edit.replacement.b)
58
+ last = edit.from
59
+ end
60
+ out.force_encoding(source.encoding)
61
+ end
62
+
63
+ private
64
+
65
+ # cond: only the value's truthiness matters here (if/unless/while
66
+ # conditions, the operand of !, and/or inside those).
67
+ def visit(node, cond)
68
+ case node
69
+ when Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode
70
+ visit(node.predicate, true)
71
+ node.compact_child_nodes.each { visit(it, false) unless it.equal?(node.predicate) }
72
+ when Prism::AndNode, Prism::OrNode
73
+ visit(node.left, cond)
74
+ visit(node.right, cond)
75
+ when Prism::ParenthesesNode
76
+ body = node.body
77
+ if body.is_a?(Prism::StatementsNode) && body.body.size == 1
78
+ visit(body.body[0], cond)
79
+ else
80
+ visit_children(node)
81
+ end
82
+ when Prism::ClassNode, Prism::ModuleNode
83
+ @nesting.push(node.constant_path.slice.delete_prefix("::"))
84
+ outer, @class_level = @class_level, 0
85
+ visit_children(node)
86
+ @class_level = outer
87
+ @nesting.pop
88
+ when Prism::SingletonClassNode, Prism::DefNode
89
+ # class << self and def self.x: self is the class, not a record
90
+ class_level = node.is_a?(Prism::SingletonClassNode) || node.receiver
91
+ @class_level += 1 if class_level
92
+ visit_children(node)
93
+ @class_level -= 1 if class_level
94
+ when Prism::CallNode
95
+ if node.name == :! && node.receiver && !node.arguments
96
+ visit(node.receiver, true)
97
+ elsif (edit = call_edit(node, cond))
98
+ @edits << edit
99
+ visit_children(node) unless edit.replacement # its parts are copied into the replacement
100
+ else
101
+ symbol_edits(node)
102
+ visit_children(node)
103
+ end
104
+ when nil
105
+ else
106
+ visit_children(node)
107
+ end
108
+ end
109
+
110
+ def visit_children(node) = node.compact_child_nodes.each { visit(it, false) }
111
+
112
+ def call_edit(node, cond)
113
+ name = node.name.to_s
114
+ entry = @index[name] or return
115
+ return if node.block || core?(name)
116
+ return if node.arguments && !entry.args # the generated method takes none: something else
117
+
118
+ receiver = node.receiver
119
+ implicit = receiver.nil? || receiver.is_a?(Prism::SelfNode)
120
+ if implicit
121
+ return if @views
122
+
123
+ case self_coverage(entry)
124
+ when :all then nil # self is always a record with the attribute
125
+ when :some then return edit(node, nil, "#{name} is removed by strict mode from #{models(entry)}, but not every class this code runs in has it: rewrite by hand")
126
+ else return # not a record with the attribute: an enum predicate, a helper, ...
127
+ end
128
+ else
129
+ if (where = @defined[name])
130
+ return edit(node, nil, "#{name} is removed by strict mode from #{models(entry)}, but #{where} defines a method of that name: rewrite calls on those records by hand")
131
+ end
132
+ if (others = @index.shadowed(name)).any?
133
+ return edit(node, nil, "#{name} is removed by strict mode from #{models(entry)}, but #{others.first(4).join(", ")} define#{"s" if others.size == 1} it otherwise (an enum, a delegate, ...): rewrite calls on those records by hand")
134
+ end
135
+ end
136
+
137
+ replacement = call_replacement(node, entry, cond)
138
+ message = replacement ? "#{name} is removed by strict mode; use #{replacement}" : "#{name} is removed by strict mode; call #{entry.proxy}(#{symbol(entry.attr)}, ...) on the record itself (it's private)"
139
+ edit(node, replacement, message)
140
+ end
141
+
142
+ def call_replacement(node, entry, cond)
143
+ receiver = node.receiver
144
+ prefix = receiver ? "#{receiver.slice}#{node.call_operator}" : ""
145
+
146
+ if entry.proxy == "attribute?"
147
+ reader = receiver || !@locals.include?(entry.attr) ? "#{prefix}#{entry.attr}" : "self.#{entry.attr}"
148
+ case entry.query
149
+ when :truthy then return cond ? reader : "#{reader}.present?" # true, false, nil, or a date/time
150
+ when :string then return "#{reader}.present?"
151
+ end
152
+ end
153
+
154
+ args = [symbol(entry.attr), node.arguments&.slice].compact.join(", ")
155
+ if (proxy = entry.public_proxy)
156
+ "#{prefix}#{proxy}(#{args})"
157
+ elsif receiver.nil? || receiver.is_a?(Prism::SelfNode)
158
+ "#{prefix}#{entry.proxy}(#{args})" # private, callable on self
159
+ end
160
+ end
161
+
162
+ # Method names passed as symbols: callback conditions become lambdas;
163
+ # send/try/delegate are reported.
164
+ def symbol_edits(node)
165
+ if SEND.include?(node.name) && (first = node.arguments&.arguments&.first).is_a?(Prism::SymbolNode)
166
+ report_symbol(first, "#{first.unescaped} is removed by strict mode; call what it stood for instead of #{node.name}")
167
+ elsif node.name == :delegate && node.receiver.nil?
168
+ node.arguments&.arguments&.grep(Prism::SymbolNode)&.each do
169
+ report_symbol(it, "#{it.unescaped} is removed by strict mode, so delegating it raises; delegate the reader and define the method")
170
+ end
171
+ end
172
+
173
+ keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements) || []
174
+ keywords.each do |pair|
175
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) && CALLBACK_OPTIONS.include?(pair.key.unescaped.to_sym)
176
+
177
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
178
+ values.grep(Prism::SymbolNode).each { callback_edit(it) }
179
+ end
180
+ end
181
+
182
+ def callback_edit(sym)
183
+ name = sym.unescaped
184
+ entry = @index[name] or return
185
+ case self_coverage(entry)
186
+ when :all then nil
187
+ when :some then return report_symbol(sym, "#{name} is removed by strict mode from #{models(entry)}, but not every class this code runs in has it")
188
+ else return
189
+ end
190
+
191
+ body =
192
+ if entry.proxy == "attribute?" && entry.query == :truthy
193
+ entry.attr # callback conditions only test truthiness
194
+ elsif entry.proxy == "attribute?" && entry.query == :string
195
+ "#{entry.attr}.present?"
196
+ else
197
+ "#{entry.public_proxy || entry.proxy}(#{symbol(entry.attr)})"
198
+ end
199
+ @edits << edit(sym, "-> { #{body} }", "#{name} is removed by strict mode; use -> { #{body} }")
200
+ end
201
+
202
+ def report_symbol(sym, message)
203
+ @edits << edit(sym, nil, message) if @index[sym.unescaped] && !core?(sym.unescaped)
204
+ end
205
+
206
+ # On every record too (readonly?, frozen?, ...) when ActiveRecord is loaded.
207
+ def core?(name)
208
+ CORE.any? { it.method_defined?(name) || it.private_method_defined?(name) } ||
209
+ (defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.method_defined?(name))
210
+ end
211
+
212
+ def models(entry)
213
+ names = entry.classes
214
+ names.size > 4 ? "#{names.first(4).join(", ")} and #{names.size - 4} more" : names.join(", ")
215
+ end
216
+
217
+ def edit(node, replacement, message)
218
+ loc = node.location
219
+ Edit.new(loc.start_offset, loc.end_offset, replacement, message)
220
+ end
221
+
222
+ # :all when self is a record of a model with the entry's attribute
223
+ # wherever this code runs (in the model, its subclasses, or every model
224
+ # including this module), :some when only in some of them, nil otherwise.
225
+ def self_coverage(entry)
226
+ return if @nesting.empty? || @class_level.positive?
227
+
228
+ name = @nesting.join("::")
229
+ classes =
230
+ if @index.model?(name) then @index.with_descendants(name)
231
+ elsif @index.app_module?(name) then @index.includers_of(name)
232
+ else return
233
+ end
234
+ covered = classes.count { entry.classes.include?(it) }
235
+ if covered == classes.size then :all
236
+ elsif covered.positive? then :some
237
+ end
238
+ end
239
+
240
+ def symbol(attr) = attr.to_sym.inspect
241
+
242
+ # Names of local variables and parameters anywhere in the file: a bare
243
+ # reader call with one of these names would read the local instead.
244
+ def locals(root)
245
+ names = Set.new
246
+ walk = lambda do |node|
247
+ type = node.class.name
248
+ names << node.name.to_s if (type.include?("LocalVariable") || type.end_with?("ParameterNode")) && node.respond_to?(:name) && node.name
249
+ node.compact_child_nodes.each(&walk)
250
+ end
251
+ walk.(root)
252
+ names
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubocopMinify
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop_minify/version"
4
+ require "rubocop_minify/rewrite"
5
+ require "rubocop_minify/definitions"
6
+
7
+ module RubocopMinify
8
+ autoload :ERB, "rubocop_minify/erb"
9
+
10
+ # One Rewrite per app root and settings: reading the manifest and scanning
11
+ # the app for definitions happens once per process.
12
+ def self.rewrite_for(root, manifest:, definition_paths:, views: false)
13
+ @rewrites ||= {}
14
+ @rewrites[[root, manifest, definition_paths, views]] ||= begin
15
+ path = File.expand_path(manifest, root)
16
+ if File.exist?(path)
17
+ require "ar_minimizer"
18
+ Rewrite.new(ArMinimizer::Manifest.load(path), defined: definitions(root, definition_paths), views: views)
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.definitions(root, paths)
24
+ @definitions ||= {}
25
+ @definitions[[root, paths]] ||= Definitions.scan(root, paths)
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-minify
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sampo Kuokkanen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord-minimizer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: herb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.11'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.11'
40
+ - !ruby/object:Gem::Dependency
41
+ name: lint_roller
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: prism
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '1.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '1.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '1.72'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '1.72'
82
+ description: RuboCop cop and ERB fixer that rewrite calls to the attribute methods
83
+ activerecord-minimizer's strict mode removes (name?, name_changed?, ...) into the
84
+ calls they stood for, from the app's manifest.
85
+ executables:
86
+ - minify-erb
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - LICENSE.txt
92
+ - README.md
93
+ - config/default.yml
94
+ - exe/minify-erb
95
+ - lib/rubocop-minify.rb
96
+ - lib/rubocop/cop/minify/attribute_methods.rb
97
+ - lib/rubocop/minify/plugin.rb
98
+ - lib/rubocop_minify.rb
99
+ - lib/rubocop_minify/definitions.rb
100
+ - lib/rubocop_minify/erb.rb
101
+ - lib/rubocop_minify/rewrite.rb
102
+ - lib/rubocop_minify/version.rb
103
+ homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/rubocop-minify
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ source_code_uri: https://github.com/sampokuokkanen/rails-minify/tree/main/rubocop-minify
108
+ changelog_uri: https://github.com/sampokuokkanen/rails-minify/blob/main/rubocop-minify/CHANGELOG.md
109
+ bug_tracker_uri: https://github.com/sampokuokkanen/rails-minify/issues
110
+ rubygems_mfa_required: 'true'
111
+ default_lint_roller_plugin: RuboCop::Minify::Plugin
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '3.4'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 4.0.20
127
+ specification_version: 4
128
+ summary: RuboCop cops (and an ERB fixer) that rewrite code relying on what rails-minify
129
+ removes from Rails
130
+ test_files: []