rubocop-gusto 11.2.0 → 11.8.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: bd7d9b067df7edfe822d63daa4c1ffb54b54086cbf80f185ca20888a513d88ec
4
- data.tar.gz: 4f62f8d399612f2aa26e929b765b33e54324bee3306c27f515b43e15f0c3111f
3
+ metadata.gz: 42abd3d1b5e56856b77781b5554a87abea61c419665b553d7bc61053203383cb
4
+ data.tar.gz: 5989d13ccea81744e6a44524003952a304df90e054a2a64ca572ec50d6e2d473
5
5
  SHA512:
6
- metadata.gz: 5393a0a5937de49f0f70bfd74f401bcabe17a0c24292a9921277d2f81dbf51661e730995b16db0c3de5888ef7e3842236016f156fc94b7446f1eb8693a8beb77
7
- data.tar.gz: 53114fe101a37c7d0a34f291e038b70c16186c8b4352991d7b7bd03e40b8b293281e62b1adc8fd4d2c6fdeabcc143bd7ff262baaef4c471e5e895f00e843803d
6
+ metadata.gz: 5f77ba3fdfcc4ad8fe904599840fc2388baf724b826e0722544d724e9f679b1e5ea2b00cb99f184a3b5b8ae45efccec0b6466cf87c43654ba33b7492f9ffd524
7
+ data.tar.gz: 64f68fa5679a0207ccd9f843447349211cc65f617a08cdd636e5903987c758945eae9317feea61f8a9286d030b3fbf2c3a4fc99168805a85d68097e79acf1672
data/CHANGELOG.md CHANGED
@@ -3,6 +3,68 @@
3
3
  - Remove redundant `Rails: Enabled: true` from `config/rails.yml` (already set by rubocop-rails' own defaults)
4
4
  - Enable `Rails/DefaultScope` cop (disabled by default in rubocop-rails)
5
5
 
6
+ ## [11.8.0](https://github.com/Gusto/rubocop-gusto/compare/v11.7.1...v11.8.0) (2026-09-02)
7
+
8
+
9
+ ### Features
10
+
11
+ * add the Gusto/Graphql cop department, backed by rubocop-graphql ([#170](https://github.com/Gusto/rubocop-gusto/issues/170)) ([98cd994](https://github.com/Gusto/rubocop-gusto/commit/98cd99467d1c50a4c3fb50f48f1dfa0e2905f6b4))
12
+
13
+ ## [11.7.1](https://github.com/Gusto/rubocop-gusto/compare/v11.7.0...v11.7.1) (2026-08-17)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * scope Gusto/VcrRecordings to specs ([#161](https://github.com/Gusto/rubocop-gusto/issues/161)) ([923d1b1](https://github.com/Gusto/rubocop-gusto/commit/923d1b1ab4b2af56a6e786afe13755aefdff502e))
19
+
20
+ ## [11.7.0](https://github.com/Gusto/rubocop-gusto/compare/v11.6.1...v11.7.0) (2026-08-11)
21
+
22
+
23
+ ### Features
24
+
25
+ * add Gusto/Sorbet/PredicateBooleanReturn (opt-in) and Gusto/SuddenAssociations cops ([#157](https://github.com/Gusto/rubocop-gusto/issues/157)) ([f123bbb](https://github.com/Gusto/rubocop-gusto/commit/f123bbbcafdf5b2e38339fa6c541444b4902eb9c))
26
+
27
+ ## [11.6.1](https://github.com/Gusto/rubocop-gusto/compare/v11.6.0...v11.6.1) (2026-07-27)
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * allow serialize/from_hash/insert_record overrides in Style/OptionalBooleanParameter ([#152](https://github.com/Gusto/rubocop-gusto/issues/152)) ([28e5a3e](https://github.com/Gusto/rubocop-gusto/commit/28e5a3eaa78b592d724a8d3b3044ef847d81e3dc))
33
+
34
+ ## [11.6.0](https://github.com/Gusto/rubocop-gusto/compare/v11.5.0...v11.6.0) (2026-07-22)
35
+
36
+
37
+ ### Features
38
+
39
+ * enable Sorbet/RedundantTLetForLiteral and bump rubocop-sorbet for RedundantTLet cops ([#154](https://github.com/Gusto/rubocop-gusto/issues/154)) ([3098b7e](https://github.com/Gusto/rubocop-gusto/commit/3098b7e413c53831354c4c9e8e5337ab743d2a27))
40
+ * enable Sorbet/RedundantTLetForLiteral, bump rubocop-sorbet for RedundantTLet cops ([3098b7e](https://github.com/Gusto/rubocop-gusto/commit/3098b7e413c53831354c4c9e8e5337ab743d2a27))
41
+
42
+
43
+ ### Bug Fixes
44
+
45
+ * remove git dependency from Gusto/UnreferencedLet ([#153](https://github.com/Gusto/rubocop-gusto/issues/153)) ([933f5b0](https://github.com/Gusto/rubocop-gusto/commit/933f5b0ec86b422caf0d6e6d460c468e8c00c408))
46
+
47
+ ## [11.5.0](https://github.com/Gusto/rubocop-gusto/compare/v11.4.0...v11.5.0) (2026-07-14)
48
+
49
+
50
+ ### Features
51
+
52
+ * add Gusto/RedundantSpecHelperRequire cop (ADR 116, with autofix) ([#149](https://github.com/Gusto/rubocop-gusto/issues/149)) ([83c22c9](https://github.com/Gusto/rubocop-gusto/commit/83c22c9ee4fb0bc8a5faff11769570009892cccd))
53
+
54
+ ## [11.4.0](https://github.com/Gusto/rubocop-gusto/compare/v11.3.0...v11.4.0) (2026-07-13)
55
+
56
+
57
+ ### Features
58
+
59
+ * add Gusto/ConstantSafety cop ([#148](https://github.com/Gusto/rubocop-gusto/issues/148)) ([7ee7925](https://github.com/Gusto/rubocop-gusto/commit/7ee7925094a7df531d1a9eb078e474fa89b3c133))
60
+
61
+ ## [11.3.0](https://github.com/Gusto/rubocop-gusto/compare/v11.2.0...v11.3.0) (2026-06-29)
62
+
63
+
64
+ ### Features
65
+
66
+ * add Gusto/FeatureFlagConstants cop (RR-890) ([#144](https://github.com/Gusto/rubocop-gusto/issues/144)) ([9058e0e](https://github.com/Gusto/rubocop-gusto/commit/9058e0e3055445a6be1818f2c142013239773bb5))
67
+
6
68
  ## [11.2.0](https://github.com/Gusto/rubocop-gusto/compare/v11.1.1...v11.2.0) (2026-06-26)
7
69
 
8
70
 
data/README.md CHANGED
@@ -32,6 +32,47 @@ This adds `rubocop-gusto` to your `.rubocop.yml` `plugins:` list and includes an
32
32
 
33
33
  If this is an existing project, it is recommended to run the autocorrector (`bundle exec rubocop -a`) and then to regenerate the `.rubocop_todo.yml` (`bundle exec rubocop --auto-gen-config`), so issues can be dealt with piecemeal.
34
34
 
35
+ #### GraphQL configuration
36
+
37
+ The `Gusto/Graphql` cops lint the [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) schema
38
+ DSL. They live in `config/graphql.yml`, which also configures the `GraphQL` cops from
39
+ [rubocop-graphql](https://github.com/DmitryTsepelev/rubocop-graphql) — several of them cops Gusto
40
+ contributed upstream rather than keep here. Neither is in `config/default.yml`, so projects without
41
+ a GraphQL schema are not linted for those patterns.
42
+
43
+ Running `bundle exec rubocop-gusto init` does all of it when `graphql` is in your `Gemfile` or
44
+ `Gemfile.lock`: adds `rubocop-graphql` to your Gemfile and `plugins:` list, and adds
45
+ `config/graphql.yml` to your `inherit_gem` list. rubocop-graphql is deliberately *not* a dependency
46
+ of this gem, for the same reason rubocop-rails is not — so rubocop-gusto stays usable from a plain
47
+ gem.
48
+
49
+ `GraphQL/DisallowedTypes` is where a project names the types it has decided not to expose. It is
50
+ inert until configured, and the reason is per type, so it needs no `Details:`:
51
+
52
+ ```yaml
53
+ GraphQL/DisallowedTypes:
54
+ Types:
55
+ Float: 'Use MyApp::GraphQL::Scalars::Decimal, which serializes as a string.'
56
+ MyApp::Scalars::LegacyDate: 'Use GraphQL::Types::ISO8601Date.'
57
+ ```
58
+
59
+ By default the `Gusto/Graphql` department is scoped to `**/graphql/**/*`.
60
+ `Gusto/Graphql/ResolverIgnoresObject` reads `NodeAccessors`, which lists methods your GraphQL base
61
+ classes provide that read the node without naming `object`; leave it empty and the cop reports
62
+ those resolvers as offenses.
63
+
64
+ Several of these cops replace a project-specific convention with a generic message. Put the
65
+ convention your project actually follows in the cop's `Details:`, and set
66
+ `AllCops: ExtraDetails: true` so it is appended to the offense message:
67
+
68
+ ```yaml
69
+ AllCops:
70
+ ExtraDetails: true
71
+
72
+ Gusto/Graphql/PaginateArrays:
73
+ Details: 'Use `paginated_list(<Type>)`, or `non_paginated_list(<Type>)` for a bounded list.'
74
+ ```
75
+
35
76
  #### Sidekiq configuration
36
77
 
37
78
  Sidekiq-specific cops live in `config/sidekiq.yml` and are **not** included in `config/default.yml`, so projects without Sidekiq are not linted for those patterns. Running `bundle exec rubocop-gusto init` adds `config/sidekiq.yml` to your `inherit_gem` list automatically when Sidekiq is listed in your `Gemfile` or `Gemfile.lock`.
@@ -52,6 +93,7 @@ If your project also uses Rails, include `config/rails.yml` as well (order does
52
93
  Custom cops live under the following namespaces:
53
94
 
54
95
  - `Gusto/` — general Gusto-specific cops (see [`lib/rubocop/cop/gusto/`](lib/rubocop/cop/gusto/))
96
+ - `Gusto/Graphql/` — cops scoped to the graphql-ruby schema DSL (see [`lib/rubocop/cop/gusto/graphql/`](lib/rubocop/cop/gusto/graphql/)); configured in [`config/graphql.yml`](config/graphql.yml)
55
97
  - `Sidekiq/` — cops scoped to Sidekiq patterns (see [`lib/rubocop/cop/sidekiq/`](lib/rubocop/cop/sidekiq/)); configured in [`config/sidekiq.yml`](config/sidekiq.yml)
56
98
  - `Rack/` — cops scoped to Rack middleware patterns (see [`lib/rubocop/cop/rack/`](lib/rubocop/cop/rack/))
57
99
 
data/config/default.yml CHANGED
@@ -274,6 +274,11 @@ Sorbet/ForbidTUnsafe:
274
274
  # reorganizing the code over silently turning off the type checker.
275
275
  Enabled: true
276
276
 
277
+ Sorbet/RedundantTLetForLiteral:
278
+ # Still marked pending upstream, we'd like to enable it now. Remove this
279
+ # override once it's no longer pending in rubocop-sorbet.
280
+ Enabled: true
281
+
277
282
  Sorbet/Refinement:
278
283
  # Still marked pending upstream, we contributed this and enable it here.
279
284
  Enabled: true
@@ -498,6 +503,11 @@ Style/OptionalBooleanParameter:
498
503
  AllowedMethods:
499
504
  - respond_to_missing?
500
505
  - perform # Sidekiq does not support keyword arguments.
506
+ - serialize # Overrides Sorbet's T::Props::Serializable#serialize(strict=true).
507
+ - from_hash # Overrides Sorbet's T::Props::Serializable#from_hash(hash, strict=false).
508
+ - insert_record # Overrides Rails' ActiveRecord::Associations::HasManyThroughAssociation#insert_record.
509
+ - lock # Overrides Rails' ActiveRecord::QueryMethods#lock(locks=true).
510
+ - lock! # Overrides Rails' ActiveRecord::Locking::Pessimistic#lock!(lock=true).
501
511
 
502
512
  Style/PercentLiteralDelimiters:
503
513
  PreferredDelimiters:
@@ -0,0 +1,44 @@
1
+ #
2
+ # This file should be inherited alongside default.yml for projects using graphql-ruby
3
+ #
4
+ # It configures both the `Gusto/Graphql` cops in this gem and the `GraphQL` cops from
5
+ # rubocop-graphql, which `init` adds to the project's Gemfile and `plugins:` list.
6
+ #
7
+ # After you add a rule, sort this file with `bundle exec rubocop-gusto sort config/graphql.yml`
8
+
9
+ GraphQL/DefaultForOptionalArgument:
10
+ Enabled: true
11
+
12
+ GraphQL/DisallowedTypes:
13
+ Enabled: true
14
+ # Nothing is disallowed until a project fills this in; the keys are matched against the
15
+ # written constant exactly or as a trailing segment, so `Float` also covers
16
+ # `GraphQL::Types::Float`.
17
+ Types: {}
18
+
19
+ GraphQL/NullabilityMismatch:
20
+ Enabled: true
21
+
22
+ Gusto/Graphql/IdFieldArgumentDescription:
23
+ Enabled: true
24
+
25
+ Gusto/Graphql/IdFieldDescription:
26
+ Enabled: true
27
+
28
+ Gusto/Graphql/IdInputArgumentDescription:
29
+ Enabled: true
30
+
31
+ Gusto/Graphql/PaginateArrays:
32
+ Enabled: true
33
+
34
+ Gusto/Graphql/PreventContextHashAccess:
35
+ Enabled: true
36
+
37
+ Gusto/Graphql/PreventFieldCanCanAction:
38
+ Enabled: true
39
+
40
+ Gusto/Graphql/ResolverIgnoresObject:
41
+ Enabled: true
42
+
43
+ Gusto/Graphql/TypeVariables:
44
+ Enabled: true
@@ -12,6 +12,9 @@
12
12
  Gusto/BootsnapLoadFile:
13
13
  Description: 'Do not use Bootsnap to load files. Use `require` instead.'
14
14
 
15
+ Gusto/ConstantSafety:
16
+ Description: 'Flags safe navigation (`&.`) on a constant (class/module or SCREAMING_CASE); constants are never nil, so `.` should be used instead.'
17
+
15
18
  Gusto/DatadogConstant:
16
19
  Exclude:
17
20
  # calling DataDog directly only allowed in initializers, its library, and tests
@@ -43,6 +46,50 @@ Gusto/FactoryClassesOrModules:
43
46
  Include:
44
47
  - 'spec/**/factories/*.rb'
45
48
 
49
+ Gusto/FeatureFlagConstants:
50
+ Description: 'FeatureFlag keys should be constants, not strings.'
51
+
52
+ Gusto/Graphql:
53
+ # GraphQL code conventionally lives under a `graphql` directory. Projects that lay it out
54
+ # differently should override this Include rather than each cop's.
55
+ Include:
56
+ - '**/graphql/**/*'
57
+
58
+ Gusto/Graphql/IdFieldArgumentDescription:
59
+ Description: 'Requires ID arguments on query fields to follow the standard description template.'
60
+ Enabled: false
61
+
62
+ Gusto/Graphql/IdFieldDescription:
63
+ Description: 'Requires ID fields to follow the standard description template.'
64
+ Enabled: false
65
+
66
+ Gusto/Graphql/IdInputArgumentDescription:
67
+ Description: 'Requires ID arguments on mutations and input objects to follow the standard description template.'
68
+ Enabled: false
69
+
70
+ Gusto/Graphql/PaginateArrays:
71
+ Description: 'Flags a GraphQL field typed as an unbounded array rather than a paginated list.'
72
+ Enabled: false
73
+
74
+ Gusto/Graphql/PreventContextHashAccess:
75
+ Description: 'Disallows hash access on the GraphQL context; use typed context methods instead.'
76
+ Enabled: false
77
+
78
+ Gusto/Graphql/PreventFieldCanCanAction:
79
+ Description: 'Flags the deprecated `can_can_action:` option on a GraphQL field declaration.'
80
+ Enabled: false
81
+
82
+ Gusto/Graphql/ResolverIgnoresObject:
83
+ Description: 'Flags a GraphQL field whose resolver never reads `object`, so the value is the same for every node.'
84
+ Enabled: false
85
+ # Methods inherited from a base class that read the node without naming `object`. A project
86
+ # whose GraphQL bases offer such helpers must list them here or the cop reports false positives.
87
+ NodeAccessors: []
88
+
89
+ Gusto/Graphql/TypeVariables:
90
+ Description: 'Requires `type_member` and `type_template` in GraphQL objects to name a type rather than `T.untyped`.'
91
+ Enabled: false
92
+
46
93
  Gusto/MinByMaxBy:
47
94
  Description: 'Checks for the use of `min` or `max` with a proc. Corrects to `min_by` or `max_by`.'
48
95
  Safe: false
@@ -89,6 +136,12 @@ Gusto/RakeConstants:
89
136
  - '**/*.rake'
90
137
  - 'Rakefile'
91
138
 
139
+ Gusto/RedundantSpecHelperRequire:
140
+ Description: "Removes an inline require 'spec_helper'/'rails_helper' already auto-required by the governing .rspec."
141
+ Include:
142
+ - '**/spec/**/*'
143
+ SafeAutoCorrect: false
144
+
92
145
  Gusto/RegexpBypass:
93
146
  Description: 'Ensures regular expressions use \A and \z anchors instead of ^ and $ for security validation.'
94
147
  Exclude:
@@ -108,6 +161,21 @@ Gusto/SmartTodoTeam:
108
161
  Description: 'TODO comments must be SmartTodo-formatted and target a valid team (CodeTeams).'
109
162
  Enabled: false
110
163
 
164
+ Gusto/Sorbet/PredicateBooleanReturn:
165
+ Description: 'Predicate methods in Sorbet-typed files should return T::Boolean.'
166
+ # Opted into via config/sorbet.yml when the project depends on Sorbet.
167
+ Enabled: false
168
+ # Autocorrect rewrites the signature and coerces the return value with `!!()`,
169
+ # which can change behavior in subtle ways.
170
+ SafeAutoCorrect: false
171
+
172
+ Gusto/SuddenAssociations:
173
+ Description: 'Prevents defining Rails associations on another class from outside the class body.'
174
+ # Opted into via config/rails.yml; the association DSL only exists in a Rails project.
175
+ Enabled: false
176
+ Exclude:
177
+ - '**/spec/**/*' # allow stubbing, although this is still odd
178
+
111
179
  Gusto/ToplevelConstants:
112
180
  Description: 'Prevents top-level constants from being defined outside of initializers.'
113
181
  Include:
@@ -137,6 +205,8 @@ Gusto/UsePaintNotColorize:
137
205
 
138
206
  Gusto/VcrRecordings:
139
207
  Description: 'VCR should be set to not record in tests. Use vcr: {record: :none}.'
208
+ Include:
209
+ - '**/spec/**/*'
140
210
 
141
211
  # We extend this via Gusto/SmartTodoTeam; don't let the upstream cop run standalone.
142
212
  SmartTodo/SmartTodoCop:
@@ -0,0 +1,24 @@
1
+ # Obsolete configuration reported by `ConfigObsoletion`, registered from
2
+ # `RuboCop::Gusto::Plugin#rules`.
3
+ #
4
+ # The `Graphql/*` department was a private, in-repo department before these cops moved into
5
+ # this gem, where they live in the `Gusto/Graphql` sub-department. `Graphql` also sits one
6
+ # capital letter away from `GraphQL`, the department shipped by rubocop-graphql, so keeping
7
+ # the old spelling would have left two near-identical departments in one config.
8
+ #
9
+ # Four of them were contributed to rubocop-graphql itself and are not duplicated here, so
10
+ # they point into that gem's `GraphQL` department instead. `PreventFloat` and
11
+ # `PreventScalarsDate` were generalized into one config-driven `GraphQL/DisallowedTypes`.
12
+ renamed:
13
+ Graphql/DefaultForOptionalArgument: GraphQL/DefaultForOptionalArgument
14
+ Graphql/IdFieldArgumentDescription: Gusto/Graphql/IdFieldArgumentDescription
15
+ Graphql/IdFieldDescription: Gusto/Graphql/IdFieldDescription
16
+ Graphql/IdInputArgumentDescription: Gusto/Graphql/IdInputArgumentDescription
17
+ Graphql/NullabilityMismatch: GraphQL/NullabilityMismatch
18
+ Graphql/PaginateArrays: Gusto/Graphql/PaginateArrays
19
+ Graphql/PreventContextHashAccess: Gusto/Graphql/PreventContextHashAccess
20
+ Graphql/PreventFieldCanCanAction: Gusto/Graphql/PreventFieldCanCanAction
21
+ Graphql/PreventFloat: GraphQL/DisallowedTypes
22
+ Graphql/PreventScalarsDate: GraphQL/DisallowedTypes
23
+ Graphql/ResolverIgnoresObject: Gusto/Graphql/ResolverIgnoresObject
24
+ Graphql/TypeVariables: Gusto/Graphql/TypeVariables
data/config/rails.yml CHANGED
@@ -21,6 +21,9 @@ Gusto/DiscouragedGem:
21
21
  Gusto/PluckOnSelect:
22
22
  Description: "Do not use `.pluck` on `.select`. Use one or the other."
23
23
 
24
+ Gusto/SuddenAssociations:
25
+ Enabled: true
26
+
24
27
  Performance/DoubleStartEndWith:
25
28
  IncludeActiveSupportAliases: true
26
29
 
data/config/sorbet.yml ADDED
@@ -0,0 +1,7 @@
1
+ #
2
+ # This file should be inherited alongside default.yml for projects using Sorbet
3
+ #
4
+ # After you add a rule, sort this file with `bundle exec rubocop-gusto sort config/sorbet.yml`
5
+
6
+ Gusto/Sorbet/PredicateBooleanReturn:
7
+ Enabled: true
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ # Flags safe navigation (`&.`) called on a constant, covering both
7
+ # class/module constants (`Model&.find`) and SCREAMING_CASE constants
8
+ # (`CONST&.each`).
9
+ #
10
+ # A constant reference is never `nil`, an undefined constant raises
11
+ # `NameError` before the call is even attempted, so the safe navigation
12
+ # operator is always redundant. Use the plain `.` operator instead.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # Model&.find(id)
17
+ # ENTITY_TYPES&.each { |type| type.to_s }
18
+ # Foo::Bar&.call
19
+ # ::Foo&.call
20
+ #
21
+ # # good
22
+ # Model.find(id)
23
+ # ENTITY_TYPES.each { |type| type.to_s }
24
+ # Foo::Bar.call
25
+ # ::Foo.call
26
+ class ConstantSafety < Base
27
+ extend AutoCorrector
28
+
29
+ MSG = "Do not use safe navigation (`&.`) on a constant; constants are never `nil`, so use `.` instead."
30
+
31
+ def on_csend(node)
32
+ return unless node.receiver.const_type?
33
+
34
+ add_offense(node.loc.dot) do |corrector|
35
+ corrector.replace(node.loc.dot, ".")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ # Enforces that `FeatureFlag.active?` is called with a constant rather than a
7
+ # string literal. Defining flag keys as constants keeps them in one place,
8
+ # makes typos a load-time error instead of a silent always-off flag, and lets
9
+ # tools find every reference to a flag.
10
+ #
11
+ # `FeatureFlag` is a constant, so it is never nil and safe navigation
12
+ # (`FeatureFlag&.active?`) is never used; the cop only handles `on_send`.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # FeatureFlag.active?("some_feature_flag")
17
+ #
18
+ # # good
19
+ # FeatureFlag.active?(SomeModule::SOME_FEATURE_FLAG)
20
+ class FeatureFlagConstants < Base
21
+ MSG = "FeatureFlag keys should be constants, not strings"
22
+ RESTRICT_ON_SEND = %i(active?).freeze
23
+
24
+ # @!method feature_flag_with_string?(node)
25
+ def_node_matcher :feature_flag_with_string?, <<~PATTERN
26
+ (send (const nil? :FeatureFlag) :active? (str _) ...)
27
+ PATTERN
28
+
29
+ def on_send(node)
30
+ add_offense(node) if feature_flag_with_string?(node)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ module Graphql
7
+ module IdDescriptionConcerns
8
+ # analytics_id is intentionally excluded
9
+ PRIMARY_ID_FIELDS = %i(id uuid).freeze
10
+
11
+ # Matches a GraphQL field declaration capturing name and type node.
12
+ FIELD_CALL_PATTERN = RuboCop::AST::NodePattern.new(<<~PATTERN)
13
+ (send nil? :field (sym $_name) $_ ...)
14
+ PATTERN
15
+
16
+ # Matches a GraphQL argument declaration capturing name and type node.
17
+ ARGUMENT_CALL_PATTERN = RuboCop::AST::NodePattern.new(<<~PATTERN)
18
+ (send nil? :argument (sym $_name) $_ ...)
19
+ PATTERN
20
+
21
+ # Mutation / input-object base classes conventionally end in one of these final
22
+ # segments (e.g. `Base::PermissionedMutation`, `Gusto::GraphQL::Objects::BaseInputObject`,
23
+ # `Base::Input`). We match on the base class's final name segment rather than a substring
24
+ # of its source, so unrelated bases that merely *contain* the word (e.g.
25
+ # `GraphQLMutationTool`, `MutationHelper`) are not misclassified.
26
+ MUTATION_OR_INPUT_BASE_SUFFIXES = %w(Mutation InputObject Input).freeze
27
+
28
+ def field_call?(node, &block)
29
+ FIELD_CALL_PATTERN.match(node, &block)
30
+ end
31
+
32
+ def argument_call?(node, &block)
33
+ ARGUMENT_CALL_PATTERN.match(node, &block)
34
+ end
35
+
36
+ private
37
+
38
+ def id_type?(type_node)
39
+ return false unless type_node
40
+ return false unless type_node.const_type?
41
+
42
+ const_name = type_node.const_name
43
+ const_name == "ID" || const_name == "GraphQL::Types::ID"
44
+ end
45
+
46
+ def array_id_type?(type_node)
47
+ return false unless type_node
48
+
49
+ # Match: [ID] or [GraphQL::Types::ID]
50
+ if type_node.array_type?
51
+ inner = type_node.children.first
52
+ return id_type?(inner)
53
+ end
54
+
55
+ false
56
+ end
57
+
58
+ def extract_description(node)
59
+ description_from_positional_argument(node) ||
60
+ description_from_keyword_argument(node) ||
61
+ description_from_block(node)
62
+ end
63
+
64
+ def description_from_positional_argument(node)
65
+ node.arguments.find(&:str_type?)&.value
66
+ end
67
+
68
+ def description_from_keyword_argument(node)
69
+ node.each_child_node(:hash) do |arg|
70
+ arg.each_pair do |key, value|
71
+ return value.value if key.sym_type? && key.value == :description && value.str_type?
72
+ end
73
+ end
74
+
75
+ nil
76
+ end
77
+
78
+ def description_from_block(node)
79
+ parent = node.parent
80
+ return nil unless parent&.block_type? && parent.send_node == node
81
+
82
+ parent.body&.each_node(:send) do |send_node|
83
+ if send_node.method?(:description) && send_node.first_argument&.str_type?
84
+ return send_node.first_argument.value
85
+ end
86
+ end
87
+
88
+ nil
89
+ end
90
+
91
+ # Find the GraphQL type name for the enclosing class or module
92
+ # Prefers graphql_name declaration over Ruby class/module name
93
+ # Returns nil if the name cannot be determined
94
+ def find_graphql_type_name(node)
95
+ # Try class first (most common), then module (for interfaces)
96
+ type_node = node.each_ancestor(:class).first || node.each_ancestor(:module).first
97
+ return nil unless type_node
98
+
99
+ # Look for graphql_name declaration in the type body
100
+ graphql_name = find_graphql_name_declaration(type_node)
101
+ return graphql_name if graphql_name
102
+
103
+ # Fall back to Ruby class/module name, stripping common suffixes
104
+ # GraphQL-ruby automatically strips "Type" from class names
105
+ # Interfaces conventionally use "Interface" suffix
106
+ # Also strip module namespacing, only use the final name
107
+ const_node = type_node.children.first
108
+ return nil unless const_node
109
+
110
+ full_name = const_node.const_name
111
+ type_name = full_name.split("::").last
112
+ strip_graphql_suffix(type_name)
113
+ end
114
+
115
+ def strip_graphql_suffix(name)
116
+ name.chomp("Type").chomp("Interface")
117
+ end
118
+
119
+ def find_graphql_name_declaration(type_node)
120
+ type_body = type_node.body
121
+ return nil unless type_body
122
+
123
+ nodes = type_body_nodes(type_body)
124
+ graphql_name_call = find_graphql_name_call(nodes)
125
+ return nil unless graphql_name_call
126
+
127
+ first_arg = graphql_name_call.first_argument
128
+ first_arg.value if first_arg&.str_type?
129
+ end
130
+
131
+ def type_body_nodes(type_body)
132
+ if type_body.begin_type?
133
+ type_body.children
134
+ else
135
+ [type_body]
136
+ end
137
+ end
138
+
139
+ def find_graphql_name_call(nodes)
140
+ nodes.find { |node| node.send_type? && node.method?(:graphql_name) }
141
+ end
142
+
143
+ def in_mutation_or_input_object?(node)
144
+ node.each_ancestor(:class).any? do |class_node|
145
+ base = class_node.parent_class
146
+ next false unless base&.const_type?
147
+
148
+ base.short_name.to_s.end_with?(*MUTATION_OR_INPUT_BASE_SUFFIXES)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "id_description_concerns"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Gusto
8
+ module Graphql
9
+ # Ensures ID type arguments on query fields follow the standardized description template:
10
+ # "The identifier of the <ObjectName> (`<ObjectName.fieldName>`) to filter by"
11
+ # "The identifiers of each <ObjectName> (`<ObjectName.fieldName>`) to filter by" (for arrays)
12
+ #
13
+ # This cop only applies to arguments on regular GraphQL objects/queries,
14
+ # NOT on mutations or input objects (see IdInputArgumentDescription for those).
15
+ #
16
+ # @example Bad
17
+ # argument :employee_id, ID, 'Employee ID', required: true
18
+ #
19
+ # @example Good
20
+ # argument :employee_id, ID, 'The identifier of the Employee (`Employee.id`) to filter by', required: true
21
+ # argument :employee_ids, [ID], 'The identifiers of each Employee (`Employee.id`) to filter by', required: true
22
+ #
23
+ class IdFieldArgumentDescription < Base
24
+ include IdDescriptionConcerns
25
+
26
+ MSG = "ID argument description should match template: " \
27
+ "'The identifier of the <ObjectName> (`<ObjectName.fieldName>`) to filter by'."
28
+
29
+ RESTRICT_ON_SEND = %i(argument).freeze
30
+
31
+ # A single "<ObjectName> (`<ObjectName.fieldName>`)" reference. Multiple may be joined
32
+ # with " or " for arguments that accept an id from more than one entity.
33
+ TYPE_REF = /\w+ \(`\w+\.\w+`\)/
34
+
35
+ # Single: "The identifier of the <ObjectName> (`<ObjectName.fieldName>`) to filter by"
36
+ SINGLE_PATTERN = /\AThe identifier of the #{TYPE_REF}(?: or #{TYPE_REF})* to filter by/
37
+
38
+ # Array: "The identifiers of each <ObjectName> (`<ObjectName.fieldName>`) to filter by"
39
+ ARRAY_PATTERN = /\AThe identifiers of each #{TYPE_REF}(?: or #{TYPE_REF})* to filter by/
40
+
41
+ def on_send(node)
42
+ # Skip if we're in a mutation or input object context because there's another cop for that
43
+ return if in_mutation_or_input_object?(node)
44
+
45
+ argument_call?(node) do |_name, type_node|
46
+ is_array = array_id_type?(type_node)
47
+ return unless id_type?(type_node) || is_array
48
+
49
+ desc = extract_description(node)
50
+ return if desc.nil? # No description - let other cops handle missing descriptions
51
+ return if valid_argument_description?(desc, is_array)
52
+
53
+ add_offense(node)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def valid_argument_description?(desc, is_array)
60
+ # Only validate the pattern format, not the object name.
61
+ # The object name should reference the actual GraphQL type,
62
+ # which may differ from what we'd infer from the argument name.
63
+ # e.g., argument :device_id could reference TrustedDevice type
64
+ pattern = is_array ? ARRAY_PATTERN : SINGLE_PATTERN
65
+ pattern.match?(desc)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end