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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a3da31c89d2b9f9926408c588371742a0ae1d8b912c56cb44273616135b528c
4
- data.tar.gz: 308439a55bc2d7c2d40a43f1d5eaf879a53d599a707f1366cf802703cfe62d83
3
+ metadata.gz: ca026117c849f4df349fe5c67aed0695aedfc780a64af122996eb3d51fc3cc28
4
+ data.tar.gz: db9a73d62f9d1f0005540ae233de188ce4fa7486272a06387ac72335b8bc3037
5
5
  SHA512:
6
- metadata.gz: 75bf1de953b0125adab24812789f19fe06dbb96c67f608a824d97fbbdc77185b08a855d18b2d3d36108cccfd53cd264220f34e49f67864f88442854b5cd09623
7
- data.tar.gz: 9c8d94ef1dddc927453f8f3548d4410c92a4d1eb1cd48873ba4b29122031c14e88021d430f830d98a145352ef188b01decb8dd1cae7f6546aefde520cd84b73e
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. Katalyst does not at present have any custom cops; this is simply a repository for our configuration of the standard cops with Rails, Rake, Performance and RSpec extensions.
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
@@ -15,6 +15,7 @@ inherit_mode:
15
15
  inherit_from:
16
16
  - "./rubocop-bundler.yml"
17
17
  - "./rubocop-gemspec.yml"
18
+ - "./rubocop-koi.yml"
18
19
  - "./rubocop-layout.yml"
19
20
  - "./rubocop-lint.yml"
20
21
  - "./rubocop-metrics.yml"
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "koi/duplicates_association"
4
+ require_relative "koi/table_link_heading"
@@ -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
@@ -6,6 +6,8 @@ require "rubocop-rails"
6
6
  require "rubocop-rake"
7
7
  require "rubocop-rspec"
8
8
 
9
+ require_relative "cop/katalyst_cops"
10
+
9
11
  module RuboCop
10
12
  module Katalyst
11
13
  class Plugin < LintRoller::Plugin
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.1
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.10
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
- }