rubocop-katalyst 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +3 -1
- data/config/default.yml +1 -0
- data/config/rubocop-koi.yml +27 -0
- data/lib/rubocop/cop/katalyst_cops.rb +4 -0
- data/lib/rubocop/cop/koi/duplicates_association.rb +117 -0
- data/lib/rubocop/cop/koi/table_link_heading.rb +145 -0
- data/lib/rubocop/katalyst.rb +2 -0
- metadata +6 -3
- data/package.json +0 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca026117c849f4df349fe5c67aed0695aedfc780a64af122996eb3d51fc3cc28
|
|
4
|
+
data.tar.gz: db9a73d62f9d1f0005540ae233de188ce4fa7486272a06387ac72335b8bc3037
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 752ce4174f0ea3121ba76a2cfe72c49771e0069dc4f940eda114f789ed13a73cc1dbe88558efb4a193765c3b5038003a8af8a2c3979a4b126d515d5b171304c2
|
|
7
|
+
data.tar.gz: d9dd3b37ec0884d11b0d08a0ceb228315174b00ffdc6736246bd1da5e9f8271f7fa1d2af91809170d5ebfc2d0f664daba801d0d8b0ac5be4ed7d46b8150859c7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [3.2.0] - 2026-08-17
|
|
2
|
+
|
|
3
|
+
- Add Koi/DuplicatesAssociation identifying Katalyst::Content items that
|
|
4
|
+
have associations but don't use the library-provided `duplicates_association`
|
|
5
|
+
helper.
|
|
6
|
+
|
|
7
|
+
## [3.1.0] - 2026-08-14
|
|
8
|
+
|
|
9
|
+
- Add Koi department for custom cops specific to katalyst-koi projects,
|
|
10
|
+
configured in `config/rubocop-koi.yml` and applied to admin views via erb_lint
|
|
11
|
+
- Add Koi/TableLinkHeading cop: tables in admin index/archived views and row
|
|
12
|
+
partials should link to the record once, from the column that identifies
|
|
13
|
+
it. A single record link must be marked `heading: true` (autocorrectable);
|
|
14
|
+
additional links should use `text`, or `heading: false` when intentional
|
|
15
|
+
- Run tests and linting in CI via `bin/ci`
|
|
16
|
+
- Remove prettier/yarn from this repository and stop shipping `package.json`
|
|
17
|
+
in the gem — nothing consumed it, and downstream `PrettierTask` installs
|
|
18
|
+
prettier in the host project itself
|
|
19
|
+
|
|
1
20
|
## [3.0.0] - 2026-05-21
|
|
2
21
|
|
|
3
22
|
- Update Ruby minimum version and syntax to 4.0
|
data/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Rubocop::Katalyst
|
|
2
2
|
|
|
3
|
-
[Katalyst's](https://katalyst.com.au) style guide for Ruby and Rails, in gem form.
|
|
3
|
+
[Katalyst's](https://katalyst.com.au) style guide for Ruby and Rails, in gem form. This is a repository for our configuration of the standard cops with Rails, Rake, Performance and RSpec extensions, plus our own custom cops.
|
|
4
4
|
|
|
5
5
|
Cops are broken down by department; a file corresponding to each department can be found in the `config` directory, as well as the `default.yml` file which contains any global configuration and loads the cops. The `.rubocop.yml` file contains the configuration for this particular project as a guide for use in non-Rails environments.
|
|
6
6
|
|
|
7
|
+
Custom cops in the `Koi` department apply to projects built on [katalyst-koi](https://github.com/katalyst/koi). They target admin views, which plain RuboCop does not inspect — they take effect when RuboCop runs through [erb_lint](https://github.com/Shopify/erb_lint) (`rake erb_lint`), which lints the Ruby inside ERB templates. If your `.erb_lint.yml` restricts the RuboCop linter with an `only:` list, add the `Koi` cops to that list.
|
|
8
|
+
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
Add this line to your application's Gemfile:
|
data/config/default.yml
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Custom cops for projects built on Koi (katalyst-koi).
|
|
2
|
+
Koi:
|
|
3
|
+
Enabled: true
|
|
4
|
+
|
|
5
|
+
Koi/DuplicatesAssociation:
|
|
6
|
+
Description: "Owned detail associations on content items should declare duplicates_association."
|
|
7
|
+
Enabled: true
|
|
8
|
+
Safe: true
|
|
9
|
+
SafeAutoCorrect: false
|
|
10
|
+
VersionAdded: "3.2"
|
|
11
|
+
BaseClasses:
|
|
12
|
+
- Katalyst::Content::Item
|
|
13
|
+
Include:
|
|
14
|
+
- "**/app/models/**/*.rb"
|
|
15
|
+
|
|
16
|
+
# Targets admin views, which are only linted when RuboCop runs through
|
|
17
|
+
# erb_lint, so it takes effect via `rake erb_lint`.
|
|
18
|
+
Koi/TableLinkHeading:
|
|
19
|
+
Description: "Tables should have a single record link, rendered as the row heading."
|
|
20
|
+
Enabled: true
|
|
21
|
+
Safe: true
|
|
22
|
+
SafeAutoCorrect: false
|
|
23
|
+
VersionAdded: "3.1"
|
|
24
|
+
Include:
|
|
25
|
+
- "**/app/views/admin/**/index.html.erb"
|
|
26
|
+
- "**/app/views/admin/**/archived.html.erb"
|
|
27
|
+
- "**/app/views/admin/**/*.html+row.erb"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Koi
|
|
6
|
+
# Content items are duplicated for copy-on-write editing, and owned
|
|
7
|
+
# detail records must be duplicated with them. katalyst-content
|
|
8
|
+
# provides `duplicates_association` for this; without it the duplicate
|
|
9
|
+
# silently loses the detail record when the item is next edited.
|
|
10
|
+
#
|
|
11
|
+
# An association is considered an owned detail when it declares
|
|
12
|
+
# `dependent: :destroy` and is autosaved, either explicitly with
|
|
13
|
+
# `autosave: true` or implicitly via `accepts_nested_attributes_for`.
|
|
14
|
+
#
|
|
15
|
+
# The cop only applies where `duplicates_association` is available:
|
|
16
|
+
# classes that extend one of the configured `BaseClasses`
|
|
17
|
+
# (`Katalyst::Content::Item` by default), include
|
|
18
|
+
# `Katalyst::Content::DuplicatesAssociations`, or already call
|
|
19
|
+
# `duplicates_association`.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# # bad
|
|
23
|
+
# class Embed < Katalyst::Content::Item
|
|
24
|
+
# has_one :embed_detail, autosave: true, dependent: :destroy
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # good
|
|
28
|
+
# class Embed < Katalyst::Content::Item
|
|
29
|
+
# has_one :embed_detail, autosave: true, dependent: :destroy
|
|
30
|
+
# duplicates_association :embed_detail
|
|
31
|
+
# end
|
|
32
|
+
class DuplicatesAssociation < Base
|
|
33
|
+
extend AutoCorrector
|
|
34
|
+
|
|
35
|
+
MSG = "Owned associations are duplicated with the item; " \
|
|
36
|
+
"declare `duplicates_association :%<name>s`."
|
|
37
|
+
|
|
38
|
+
RESTRICT_ON_SEND = %i[has_one has_many].freeze
|
|
39
|
+
|
|
40
|
+
BASE_CLASSES_DEFAULT = ["Katalyst::Content::Item"].freeze
|
|
41
|
+
|
|
42
|
+
# @!method association(node)
|
|
43
|
+
def_node_matcher :association, <<~PATTERN
|
|
44
|
+
(send nil? {:has_one :has_many} (sym $_) $(hash ...))
|
|
45
|
+
PATTERN
|
|
46
|
+
|
|
47
|
+
# @!method duplicates_association_names(node)
|
|
48
|
+
def_node_search :duplicates_association_names, <<~PATTERN
|
|
49
|
+
(send nil? :duplicates_association (sym $_)+)
|
|
50
|
+
PATTERN
|
|
51
|
+
|
|
52
|
+
# @!method includes_concern?(node)
|
|
53
|
+
def_node_search :includes_concern?, <<~PATTERN
|
|
54
|
+
(send nil? :include (const _ :DuplicatesAssociations))
|
|
55
|
+
PATTERN
|
|
56
|
+
|
|
57
|
+
# @!method nested_attributes(node)
|
|
58
|
+
def_node_search :nested_attributes, <<~PATTERN
|
|
59
|
+
$(send nil? :accepts_nested_attributes_for (sym $_) ...)
|
|
60
|
+
PATTERN
|
|
61
|
+
|
|
62
|
+
def on_send(node)
|
|
63
|
+
association(node) do |name, options|
|
|
64
|
+
klass = node.each_ancestor(:class).first
|
|
65
|
+
return unless klass
|
|
66
|
+
return unless owned?(klass, name, options)
|
|
67
|
+
return unless duplication_available?(klass)
|
|
68
|
+
return if declared?(klass, name)
|
|
69
|
+
|
|
70
|
+
add_offense(node, message: format(MSG, name:)) do |corrector|
|
|
71
|
+
anchor = nested_attributes_node(klass, name) || node
|
|
72
|
+
corrector.insert_after(anchor, "\n#{' ' * anchor.loc.column}duplicates_association :#{name}")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def owned?(klass, name, options)
|
|
80
|
+
option?(options, :dependent, :destroy) &&
|
|
81
|
+
(option?(options, :autosave, true) || nested_attributes_node(klass, name))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def option?(options, key, value)
|
|
85
|
+
options.pairs.any? do |pair|
|
|
86
|
+
pair.key.sym_type? && pair.key.value == key && option_value?(pair.value, value)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def option_value?(node, value)
|
|
91
|
+
case value
|
|
92
|
+
when true then node.true_type?
|
|
93
|
+
when Symbol then node.sym_type? && node.value == value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def duplication_available?(klass)
|
|
98
|
+
base_classes.include?(klass.parent_class&.source) ||
|
|
99
|
+
includes_concern?(klass) ||
|
|
100
|
+
duplicates_association_names(klass).any?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def base_classes
|
|
104
|
+
cop_config.fetch("BaseClasses", BASE_CLASSES_DEFAULT)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def declared?(klass, name)
|
|
108
|
+
duplicates_association_names(klass).any? { |names| names.include?(name) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def nested_attributes_node(klass, name)
|
|
112
|
+
nested_attributes(klass).find { |_node, sym| sym == name }&.first
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Koi
|
|
6
|
+
# Koi admin tables should link to each record exactly once, from the
|
|
7
|
+
# column that identifies the record, and that link should render as a
|
|
8
|
+
# row heading (`th`) for accessibility.
|
|
9
|
+
#
|
|
10
|
+
# When a table renders a single record link, mark it as the row heading
|
|
11
|
+
# with `heading: true` (autocorrectable). When a table renders more
|
|
12
|
+
# than one record link, the extra columns should use `text` instead;
|
|
13
|
+
# `heading: false` documents an intentional exception. Links that
|
|
14
|
+
# override `url:` are not record links, so they are ignored, as are
|
|
15
|
+
# links that forward options (`**options`).
|
|
16
|
+
#
|
|
17
|
+
# erb_lint feeds RuboCop one ERB tag at a time, so the fragment being
|
|
18
|
+
# inspected is not enough context to count links. When linting a view,
|
|
19
|
+
# this cop reads the whole file from disk and counts record links
|
|
20
|
+
# across all of its ERB tags. Outside a view file it counts links in
|
|
21
|
+
# the current source. Counting is per file, so a view that renders two
|
|
22
|
+
# tables is treated as one.
|
|
23
|
+
#
|
|
24
|
+
# This cop only takes effect through erb_lint: plain `rubocop` does not
|
|
25
|
+
# inspect `.erb` files, and the `Include` configuration restricts it to
|
|
26
|
+
# Koi admin views.
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# # bad
|
|
30
|
+
# row.link(:name)
|
|
31
|
+
#
|
|
32
|
+
# # good
|
|
33
|
+
# row.link(:name, heading: true)
|
|
34
|
+
#
|
|
35
|
+
# # good - not a record link
|
|
36
|
+
# row.link(:name, url: :edit_admin_page_path)
|
|
37
|
+
#
|
|
38
|
+
# # good - intentional extra link
|
|
39
|
+
# row.link(:name, heading: true)
|
|
40
|
+
# row.link(:homepage, heading: false)
|
|
41
|
+
class TableLinkHeading < Base
|
|
42
|
+
extend AutoCorrector
|
|
43
|
+
|
|
44
|
+
MSG_HEADING = "The record link should be the row heading; add `heading: true`."
|
|
45
|
+
MSG_EXTRA = "Tables should link to the record once, from the column that identifies it. " \
|
|
46
|
+
"Use `text` instead, or `heading: false` to keep an intentional extra link."
|
|
47
|
+
|
|
48
|
+
RESTRICT_ON_SEND = %i[link].freeze
|
|
49
|
+
|
|
50
|
+
# How erb_lint trims trailing block expressions from ERB tags, copied
|
|
51
|
+
# from Rails: action_view/template/handlers/erb/erubi.rb
|
|
52
|
+
BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
|
|
53
|
+
|
|
54
|
+
ERB_TAG = /<%(?:(?!%>).)*%>/m
|
|
55
|
+
|
|
56
|
+
# @!method row_link?(node)
|
|
57
|
+
def_node_matcher :row_link?, <<~PATTERN
|
|
58
|
+
(send {(send nil? :row) (lvar :row)} :link _ ...)
|
|
59
|
+
PATTERN
|
|
60
|
+
|
|
61
|
+
def on_new_investigation
|
|
62
|
+
super
|
|
63
|
+
@record_link_count = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def on_send(node)
|
|
67
|
+
return unless row_link?(node)
|
|
68
|
+
|
|
69
|
+
options = options(node)
|
|
70
|
+
return if options && explicit_options?(options)
|
|
71
|
+
|
|
72
|
+
anchor = node.arguments.reject(&:block_pass_type?).last
|
|
73
|
+
return unless anchor
|
|
74
|
+
|
|
75
|
+
if record_link_count > 1
|
|
76
|
+
add_offense(node, message: MSG_EXTRA)
|
|
77
|
+
else
|
|
78
|
+
add_offense(node, message: MSG_HEADING) do |corrector|
|
|
79
|
+
corrector.insert_after(anchor, ", heading: true")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def options(node)
|
|
87
|
+
arg = node.arguments.reject(&:block_pass_type?).last
|
|
88
|
+
arg if arg&.hash_type?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Skip if heading is already set, options are forwarded (splat), or
|
|
92
|
+
# the link is not a record link (url override).
|
|
93
|
+
def explicit_options?(options)
|
|
94
|
+
options.children.any?(&:kwsplat_type?) ||
|
|
95
|
+
options.pairs.any? { |pair| pair.key.sym_type? && %i[heading url].include?(pair.key.value) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A `row.link` renders a record link unless it overrides `url:`.
|
|
99
|
+
# Links that forward options are indeterminate, so they don't count.
|
|
100
|
+
def record_link?(node)
|
|
101
|
+
return false unless row_link?(node)
|
|
102
|
+
|
|
103
|
+
options = options(node)
|
|
104
|
+
return true if options.nil?
|
|
105
|
+
|
|
106
|
+
options.children.none?(&:kwsplat_type?) &&
|
|
107
|
+
options.pairs.none? { |pair| pair.key.sym_type? && pair.key.value == :url }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def record_link_count
|
|
111
|
+
@record_link_count ||= if (source = view_source)
|
|
112
|
+
count_record_links_in_erb(source)
|
|
113
|
+
else
|
|
114
|
+
count_record_links(processed_source.ast)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def view_source
|
|
119
|
+
path = processed_source.file_path
|
|
120
|
+
|
|
121
|
+
File.read(path) if path&.end_with?(".erb") && File.file?(path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def count_record_links_in_erb(source)
|
|
125
|
+
source.scan(ERB_TAG).sum do |tag|
|
|
126
|
+
next 0 if tag.start_with?("<%#")
|
|
127
|
+
|
|
128
|
+
code = tag.sub(/\A<%[=-]*/, "").sub(/-?%>\z/, "").sub(BLOCK_EXPR, "")
|
|
129
|
+
count_record_links(parse_fragment(code))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def count_record_links(ast)
|
|
134
|
+
return 0 if ast.nil?
|
|
135
|
+
|
|
136
|
+
ast.each_node(:send).count { |node| record_link?(node) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_fragment(code)
|
|
140
|
+
ProcessedSource.new(code, processed_source.ruby_version).ast
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/rubocop/katalyst.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-katalyst
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Katalyst Interactive
|
|
@@ -148,6 +148,7 @@ files:
|
|
|
148
148
|
- config/default.yml
|
|
149
149
|
- config/rubocop-bundler.yml
|
|
150
150
|
- config/rubocop-gemspec.yml
|
|
151
|
+
- config/rubocop-koi.yml
|
|
151
152
|
- config/rubocop-layout.yml
|
|
152
153
|
- config/rubocop-lint.yml
|
|
153
154
|
- config/rubocop-metrics.yml
|
|
@@ -158,12 +159,14 @@ files:
|
|
|
158
159
|
- config/rubocop-rspec.yml
|
|
159
160
|
- config/rubocop-security.yml
|
|
160
161
|
- config/rubocop-style.yml
|
|
162
|
+
- lib/rubocop/cop/katalyst_cops.rb
|
|
163
|
+
- lib/rubocop/cop/koi/duplicates_association.rb
|
|
164
|
+
- lib/rubocop/cop/koi/table_link_heading.rb
|
|
161
165
|
- lib/rubocop/katalyst.rb
|
|
162
166
|
- lib/rubocop/katalyst/erb_lint_task.rb
|
|
163
167
|
- lib/rubocop/katalyst/prettier_task.rb
|
|
164
168
|
- lib/rubocop/katalyst/rake_task.rb
|
|
165
169
|
- lib/tasks/erb_lint.rake
|
|
166
|
-
- package.json
|
|
167
170
|
homepage: https://github.com/katalyst/rubocop-katalyst
|
|
168
171
|
licenses:
|
|
169
172
|
- MIT
|
|
@@ -185,7 +188,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
185
188
|
- !ruby/object:Gem::Version
|
|
186
189
|
version: '0'
|
|
187
190
|
requirements: []
|
|
188
|
-
rubygems_version: 4.0.
|
|
191
|
+
rubygems_version: 4.0.16
|
|
189
192
|
specification_version: 4
|
|
190
193
|
summary: Code standards for Katalyst
|
|
191
194
|
test_files: []
|
data/package.json
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"private": true,
|
|
3
|
-
"scripts": {
|
|
4
|
-
"lint": "prettier --check *.json app/assets/javascripts app/assets/stylesheets",
|
|
5
|
-
"autocorrect": "prettier --write *.json app/assets/javascripts app/assets/stylesheets"
|
|
6
|
-
},
|
|
7
|
-
"devDependencies": {
|
|
8
|
-
"prettier": "3.8.3"
|
|
9
|
-
}
|
|
10
|
-
}
|