rubocop-katalyst 3.1.0 → 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: 408d4a24b9d76524197c2d2773434353c0a90752578516322fbf78731d13839e
4
- data.tar.gz: cc011b88f7a8798268c95a79da2ed7c1a7fa0da56b323e11735ff9ca1af22cce
3
+ metadata.gz: ca026117c849f4df349fe5c67aed0695aedfc780a64af122996eb3d51fc3cc28
4
+ data.tar.gz: db9a73d62f9d1f0005540ae233de188ce4fa7486272a06387ac72335b8bc3037
5
5
  SHA512:
6
- metadata.gz: 44dd1cab9b0424474fbaf1829d9279d6ae43e6e9a375b6f15f66ada55c5deaa7df66b306c79a2bc6769754797d0bbbd41d6942614c1efe93806e6f96f949f56b
7
- data.tar.gz: 1cf92f1bf724c885a5bbaf4773c42d2bd9a33bcdd3f3f21edf6c0353953084c286e66976aa946bd5ef61f8753b8fc0eafab8ea9d5cf5873d28e7ebda52228a8c
6
+ metadata.gz: 752ce4174f0ea3121ba76a2cfe72c49771e0069dc4f940eda114f789ed13a73cc1dbe88558efb4a193765c3b5038003a8af8a2c3979a4b126d515d5b171304c2
7
+ data.tar.gz: d9dd3b37ec0884d11b0d08a0ceb228315174b00ffdc6736246bd1da5e9f8271f7fa1d2af91809170d5ebfc2d0f664daba801d0d8b0ac5be4ed7d46b8150859c7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
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
+
1
7
  ## [3.1.0] - 2026-08-14
2
8
 
3
9
  - Add Koi department for custom cops specific to katalyst-koi projects,
@@ -1,10 +1,20 @@
1
1
  # Custom cops for projects built on Koi (katalyst-koi).
2
- #
3
- # These cops target admin views, which are only linted when RuboCop runs
4
- # through erb_lint, so they take effect via `rake erb_lint`.
5
2
  Koi:
6
3
  Enabled: true
7
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`.
8
18
  Koi/TableLinkHeading:
9
19
  Description: "Tables should have a single record link, rendered as the row heading."
10
20
  Enabled: true
@@ -1,3 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "koi/duplicates_association"
3
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
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.1.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
@@ -160,6 +160,7 @@ files:
160
160
  - config/rubocop-security.yml
161
161
  - config/rubocop-style.yml
162
162
  - lib/rubocop/cop/katalyst_cops.rb
163
+ - lib/rubocop/cop/koi/duplicates_association.rb
163
164
  - lib/rubocop/cop/koi/table_link_heading.rb
164
165
  - lib/rubocop/katalyst.rb
165
166
  - lib/rubocop/katalyst/erb_lint_task.rb