rubocop-dev_doc 0.16.0 → 0.17.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: 05e1aabe21621ec2ef5867764976fcfb58d7e0bb42b6224f56065ac17bb5cd8b
4
- data.tar.gz: 2adbd5114fa685aaf7240c04df9fadb955d2cba1a0ccfd83d1e8fb44751f9743
3
+ metadata.gz: 11175a594b0adb68ca20c3e2ff0ef414d1136d55152cd010a8d95ee26601a759
4
+ data.tar.gz: 460f211f62e554bead8cd267a4c3e918159eccff748d2d03f0f06b56454d025e
5
5
  SHA512:
6
- metadata.gz: ffe0a1d90cdf8d0a4dcee8ba865631f9d9c8e3a1e2b3de6af25aa6eb9441a966d715d099e3824e40ec5ec2640c001d9043f375e81fc590732309554b81b80e18
7
- data.tar.gz: 1d164d6cc051b8f216384ef334d0b5015a08d52a71cdb9b93e0cf065b6440e9a7285c3d4904d83be92936631b638428ff1dd816f39ace4a76b6053ee98b184b4
6
+ metadata.gz: 5b5a9ae0aed54ff1979d4deb8313ef53e0e8b3d831e9d3bbdabaf9c4d9c3897acd7486054cbd012222453c9e6dbcd0054821ea83ddac71c1231bc51d7be6072e
7
+ data.tar.gz: e6acacc3e7f65769104c90d5043a08c6385983ab6c9f38db8088ff30193044ac1cd6e14b365c1cbf521de52d445b84e9c4312f75e2dcb6ab93370077a8134bed
data/config/default.yml CHANGED
@@ -403,6 +403,18 @@ DevDoc/Rails/ApplicationRecordTransaction:
403
403
  Exclude:
404
404
  - "app/models/**/*.rb"
405
405
 
406
+ DevDoc/Rails/ApplicationModelBase:
407
+ Description: "Inherit the app's ApplicationModel root instead of including ActiveModel::Model directly (three-way rule, backend/03_model.md item 9)."
408
+ # Disabled by default: requires the project to have an ApplicationModel root
409
+ # over glib-web's Glib::Model. Enable per project, scoped to app/models; the
410
+ # root itself and any engine-side base need excluding where they legitimately
411
+ # carry the include.
412
+ Enabled: false
413
+ Include:
414
+ - "app/models/**/*.rb"
415
+ Exclude:
416
+ - "app/models/application_model.rb"
417
+
406
418
  DevDoc/Style/AvoidOptionsHash:
407
419
  Description: "Use keyword arguments instead of `**options` — typos raise `ArgumentError`; options hashes swallow them silently."
408
420
  Enabled: true
@@ -0,0 +1,138 @@
1
+ require 'pathname'
2
+
3
+ module DevDoc
4
+ module Test
5
+ module Lints
6
+ # Runtime check: every CLASS defined by a file under `app/models/`
7
+ # (excluding `concerns/`) must descend from one of the project's domain
8
+ # base classes. Runs with the app loaded, so real ancestry is checked —
9
+ # intermediate family bases and indirect inheritance resolve correctly,
10
+ # which is exactly what a per-file static cop cannot do.
11
+ #
12
+ # Wrapped by the Minitest module `DomainClassBase` below — see that
13
+ # module for the rationale.
14
+ class DomainClassBaseChecker
15
+ # The three-way rule (best_practices/backend/en/03_model.md item 9):
16
+ # persisted -> ApplicationRecord; table-less but form-backed ->
17
+ # ApplicationModel; plain domain logic -> PlainModel.
18
+ DEFAULT_BASE_CLASS_NAMES = %w[ApplicationRecord ApplicationModel PlainModel].freeze
19
+
20
+ def initialize(project_root, base_class_names: DEFAULT_BASE_CLASS_NAMES, allowed_paths: [])
21
+ @project_root = Pathname(project_root)
22
+ @base_class_names = base_class_names
23
+ @allowed_paths = allowed_paths
24
+ end
25
+
26
+ # Returns an Array<String> of offender descriptions, or `[]` when
27
+ # every model class descends from an allowed base. Modules are skipped:
28
+ # the three-way rule classifies CLASSES — namespaces, function-bag
29
+ # modules, and concerns carry no instance state to classify.
30
+ def offenders
31
+ bases = @base_class_names.map(&:constantize)
32
+
33
+ model_files.filter_map do |path|
34
+ constant = constant_for(path)
35
+ next if constant.nil? # module or namespace-only file
36
+ next if bases.any? { |base| constant <= base }
37
+
38
+ " #{relative(path)}: #{constant.name} < #{constant.superclass.name} — #{hint_for(constant)}"
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def model_files
45
+ Dir.glob(@project_root.join('app/models/**/*.rb')).reject do |path|
46
+ relative = relative(path)
47
+ relative.start_with?('app/models/concerns/') ||
48
+ @allowed_paths.any? { |allowed| relative.start_with?(allowed) }
49
+ end
50
+ end
51
+
52
+ # Zeitwerk guarantees the file defines the constant its path names, so
53
+ # deriving it from the path (rather than parsing the source) is exact,
54
+ # and nested helper classes (e.g. error classes inside a model) are
55
+ # never enumerated.
56
+ def constant_for(path)
57
+ name = relative(path).delete_prefix('app/models/').delete_suffix('.rb').camelize
58
+ constant = name.constantize
59
+ constant.is_a?(Class) ? constant : nil
60
+ end
61
+
62
+ def hint_for(constant)
63
+ if constant.include?(ActiveModel::Model)
64
+ 'it includes ActiveModel::Model, so inherit ApplicationModel instead'
65
+ else
66
+ 'inherit PlainModel (or ApplicationRecord/ApplicationModel if it persists or backs a form)'
67
+ end
68
+ end
69
+
70
+ def relative(path)
71
+ Pathname(path).relative_path_from(@project_root).to_s
72
+ end
73
+ end
74
+
75
+ # Domain-class base tripwire: every class under `app/models/` must be one
76
+ # of the project's three kinds — persisted (ApplicationRecord),
77
+ # form-backed (ApplicationModel), or plain domain logic (PlainModel).
78
+ #
79
+ # ## Rationale
80
+ # The three-way rule (backend/03_model.md item 9) makes the author
81
+ # classify each domain class at creation time; the base's docstring then
82
+ # states the contract that kind carries. Static analysis cannot enforce
83
+ # totality — RuboCop sees one file at a time, so it cannot resolve
84
+ # whether `class Foo < SomeFamilyBase` ultimately reaches an allowed
85
+ # base. This lint checks real ancestry with the app loaded, so
86
+ # intermediate bases resolve and the rule is enforced literally.
87
+ # `Rails/ApplicationRecord` and `DevDoc/Rails/ApplicationModelBase`
88
+ # remain useful beside it for editor-time feedback on the two common
89
+ # direct mistakes.
90
+ #
91
+ # NOTE: Limitations:
92
+ # - Only classes whose files live under `app/models/` are checked; a
93
+ # domain class parked elsewhere is invisible (placement itself is the
94
+ # orchestration taxonomy's reviewer-owned residual).
95
+ # - Modules are skipped by design (namespaces, function-bag modules,
96
+ # concerns) — the rule classifies classes.
97
+ #
98
+ # ## Usage
99
+ # Include this module in a Minitest test class (Rails test env) in a
100
+ # project whose three bases exist. Override the constants on the test
101
+ # class to rename bases or exempt a sanctioned file:
102
+ #
103
+ # class DomainClassBaseTest < ActiveSupport::TestCase
104
+ # include DevDoc::Test::Lints::DomainClassBase
105
+ # # DOMAIN_BASE_CLASS_NAMES = %w[ApplicationRecord ApplicationModel PlainModel].freeze
106
+ # # ALLOWED_DOMAIN_CLASS_PATHS = %w[app/models/legacy/].freeze
107
+ # end
108
+ module DomainClassBase
109
+ # Defaults. Per-project override: redefine the constants on the test
110
+ # class that includes this module.
111
+ DOMAIN_BASE_CLASS_NAMES = DomainClassBaseChecker::DEFAULT_BASE_CLASS_NAMES
112
+ ALLOWED_DOMAIN_CLASS_PATHS = [].freeze
113
+
114
+ def test_every_model_class_descends_from_a_domain_base
115
+ offenders = DomainClassBaseChecker.new(
116
+ Rails.root,
117
+ base_class_names: self.class::DOMAIN_BASE_CLASS_NAMES,
118
+ allowed_paths: self.class::ALLOWED_DOMAIN_CLASS_PATHS
119
+ ).offenders
120
+
121
+ assert offenders.empty?, domain_class_base_message(offenders)
122
+ end
123
+
124
+ private
125
+
126
+ def domain_class_base_message(offenders)
127
+ "Classes under app/models must descend from one of " \
128
+ "#{self.class::DOMAIN_BASE_CLASS_NAMES.join(' / ')} — the three-way rule " \
129
+ "(backend/03_model.md item 9): persisted -> ApplicationRecord, form-backed " \
130
+ "-> ApplicationModel, plain domain logic -> PlainModel. If a class is a " \
131
+ "sanctioned exception, add its path to ALLOWED_DOMAIN_CLASS_PATHS on the " \
132
+ "including test class with a comment.\n\n" \
133
+ "Offenders:\n#{offenders.join("\n")}"
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,56 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Inherit the app's `ApplicationModel` root instead of including
6
+ # `ActiveModel::Model` directly in a class under `app/models`.
7
+ #
8
+ # ## Rationale
9
+ # Table-less form-backed domain objects follow the three-way rule
10
+ # (backend/03_model.md item 9): persisted classes inherit
11
+ # `ApplicationRecord`, form-backed POROs inherit `ApplicationModel`
12
+ # (a thin app root over `Glib::Model`, which carries the shared
13
+ # mechanics such as `attr_id_list`), and plain domain logic inherits
14
+ # `PlainModel`. A direct `include ActiveModel::Model` bypasses the
15
+ # shared root: the class silently misses the shared mechanics, and the
16
+ # author never makes the which-kind-is-this decision the rule exists
17
+ # to force. This is the ActiveModel analog of `Rails/ApplicationRecord`.
18
+ #
19
+ # This cop gives editor-time feedback on the single most common direct
20
+ # mistake; totality (every model class descends from one of the three
21
+ # bases, through any intermediate family base) is enforced at test time
22
+ # by `DevDoc::Test::Lints::DomainClassBase`, since resolving indirect
23
+ # ancestry is beyond per-file static analysis.
24
+ #
25
+ # Disabled by default: enable it in projects whose `ApplicationModel`
26
+ # root exists (it requires glib-web's `Glib::Model`).
27
+ #
28
+ # @example
29
+ # # bad
30
+ # class BulkOperation
31
+ # include ActiveModel::Model
32
+ # end
33
+ #
34
+ # # good
35
+ # class BulkOperation < ApplicationModel
36
+ # end
37
+ class ApplicationModelBase < Base
38
+ MSG = 'Inherit `ApplicationModel` instead of including `ActiveModel::Model` directly — ' \
39
+ 'the shared root carries the form-object mechanics (backend/03_model.md item 9).'.freeze
40
+
41
+ RESTRICT_ON_SEND = %i[include].freeze
42
+
43
+ def_node_matcher :active_model_include?, <<~PATTERN
44
+ (send nil? :include (const (const {nil? cbase} :ActiveModel) :Model))
45
+ PATTERN
46
+
47
+ def on_send(node)
48
+ return unless active_model_include?(node)
49
+
50
+ add_offense(node)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.16.0".freeze
3
+ VERSION = "0.17.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-28 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -95,6 +95,7 @@ files:
95
95
  - lib/dev_doc/test/lints/cron_schedule.rb
96
96
  - lib/dev_doc/test/lints/cross_tenant_canary_check.rb
97
97
  - lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb
98
+ - lib/dev_doc/test/lints/domain_class_base.rb
98
99
  - lib/dev_doc/test/lints/duplicate_snapshot.rb
99
100
  - lib/dev_doc/test/lints/enqueue_disable_naming.rb
100
101
  - lib/dev_doc/test/lints/external_io_boundary.rb
@@ -133,6 +134,7 @@ files:
133
134
  - lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
134
135
  - lib/rubocop/cop/dev_doc/migration/require_reference_foreign_key.rb
135
136
  - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
137
+ - lib/rubocop/cop/dev_doc/rails/application_model_base.rb
136
138
  - lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
137
139
  - lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb
138
140
  - lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb