sequra-style 1.17.0 → 1.19.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: e247920824c3d061c6385b8c29a20843d4b1ee27fbca2d6fa3a498f7b4ff6833
4
- data.tar.gz: 66fbba9730edfbc25eb5224438bfff22bba38d72399169fcab016633f23fe049
3
+ metadata.gz: 47e2880848be7928ef68435e320055b4d2003f4b321d172094fa77f123f5139e
4
+ data.tar.gz: d8ef008a1b7ba0477c45a19d52eb965d86a994fe5c7c203c67ecc2941f2ac798
5
5
  SHA512:
6
- metadata.gz: 20d6f4326a2b796cae26c3cb7457e80613f9ddd85704cf028d8a8307602293c77d67c22942974e7b3242d7bcfe5f7113dbb9760c6c5704785910e6b54f84fb9f
7
- data.tar.gz: e36aa35cf7d5158c17d83f974fe9ac36f5d0e447521eebdeed4f6f4d8d49aec244af1e97b80ea63e9e8001c4b98c354ac7b095791d66240dfe85fdd6faa157b2
6
+ metadata.gz: 19d7b3760cf692ed282a02b99416f5d3e6f9fc288a8f5ecf8086e40459215f8665c27fc90f336183e42dcc37823668364a9d8503fa8a462b3afc5ad0d68885cb
7
+ data.tar.gz: 17bd16a9f9499060f4ee8e986c0f7a12e32a5a1f5d28ba494b9b5ca2d6ac8e78dda03254ea83d75c8073bc7823c60a738f171dd492e76910269fd41f1f5574da
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.19.0](https://github.com/sequra/sequra-style/compare/v1.18.0...v1.19.0) (2026-07-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * [COR-1992] Flag mailer .later stubs in NoSidekiqPerformStubs ([#81](https://github.com/sequra/sequra-style/issues/81)) ([b9ea46e](https://github.com/sequra/sequra-style/commit/b9ea46e97649b40aae30bfa4f6cbbc51fe0c72f0))
9
+
10
+ ## [1.18.0](https://github.com/sequra/sequra-style/compare/v1.17.0...v1.18.0) (2026-07-28)
11
+
12
+
13
+ ### Features
14
+
15
+ * **rubocop:** add PrometheusMetricLabels cop ([#79](https://github.com/sequra/sequra-style/issues/79)) ([a8f3ec1](https://github.com/sequra/sequra-style/commit/a8f3ec16cdf9779ad391c7032eca2b109892569d))
16
+
3
17
  ## [1.17.0](https://github.com/sequra/sequra-style/compare/v1.16.0...v1.17.0) (2026-07-20)
4
18
 
5
19
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sequra-style (1.17.0)
4
+ sequra-style (1.19.0)
5
5
  rubocop (~> 1.75)
6
6
  rubocop-performance (~> 1.25)
7
7
  rubocop-rails (~> 2.31)
data/default.yml CHANGED
@@ -857,3 +857,14 @@ Sequra/NoSidekiqPerformStubs:
857
857
  - "**/*_spec.rb"
858
858
  - "**/spec/support/**/*.rb"
859
859
  - "**/spec/shared_examples/**/*.rb"
860
+
861
+ # Catch malformed labels on prometheus_exporter metric calls. `increment`,
862
+ # `decrement` and `observe` take labels as a positional hash, so a `labels:`
863
+ # keyword silently produces a series labelled `labels="{...}"` that no
864
+ # by-label query can match. Also catches the value/labels argument order,
865
+ # which is reversed between `observe` and `increment`. Autocorrectable.
866
+ # `warning` severity: these are silently broken metrics, not style, so they
867
+ # fail the build rather than waiting for adoption.
868
+ Sequra/PrometheusMetricLabels:
869
+ Enabled: true
870
+ Severity: warning
@@ -2,7 +2,8 @@ module RuboCop
2
2
  module Cop
3
3
  module Sequra
4
4
  # Detects RSpec stubs and spy assertions on Sidekiq enqueue methods
5
- # (`perform_async`, `perform_at`, `perform_in`).
5
+ # (`perform_async`, `perform_at`, `perform_in`) and on the mailer
6
+ # enqueue wrapper (`SomeMailer.later`).
6
7
  #
7
8
  # Stubbing these methods bypasses `Sidekiq.strict_args!` validation,
8
9
  # which lets symbol-keyed hashes (and other non-JSON-native types)
@@ -12,12 +13,19 @@ module RuboCop
12
13
  # rescue-swallowers can mask the failure. This pattern caused a
13
14
  # silent push-notification drop in COR-1923.
14
15
  #
16
+ # `SomeMailer.later` (seQura's `ApplicationMailer.later`) is the
17
+ # standard mailer enqueue path and funnels through
18
+ # `ApplicationMailerJob.perform_async`, so stubbing it bypasses
19
+ # `strict_args!` exactly like stubbing `perform_async` directly.
20
+ # `.later` is only flagged when the stubbed subject is a `*Mailer`
21
+ # constant, so unrelated `.later` methods are left alone.
22
+ #
15
23
  # Use `have_enqueued_sidekiq_job` (or `change(SomeJob.jobs, :size)`)
16
24
  # instead. Both go through Sidekiq's client middleware chain, so
17
25
  # `strict_args!` validates the arguments as it would in production.
18
26
  #
19
27
  # `.and_call_original` is exempt: the stub spies on the call but
20
- # then runs the real `perform_*`, which still exercises the
28
+ # then runs the real method, which still exercises the
21
29
  # serialization contract.
22
30
  #
23
31
  # @example
@@ -33,6 +41,9 @@ module RuboCop
33
41
  # # bad - spy verification on a stubbed enqueue
34
42
  # expect(SomeJob).to have_received(:perform_async).with(id)
35
43
  #
44
+ # # bad - mailer enqueue wrapper bypasses strict_args! too
45
+ # allow(WelcomeMailer).to receive(:later)
46
+ #
36
47
  # # good - exercises strict_args! through Sidekiq's middleware
37
48
  # expect(SomeJob).to have_enqueued_sidekiq_job(id, name: "value")
38
49
  #
@@ -43,17 +54,23 @@ module RuboCop
43
54
  # expect(SomeJob).to receive(:perform_async).and_call_original
44
55
  #
45
56
  class NoSidekiqPerformStubs < Base
46
- MSG = "Avoid stubbing Sidekiq enqueue methods (`perform_async`/`perform_at`/`perform_in`). " \
47
- "Stubs bypass `Sidekiq.strict_args!` validation and can hide symbol-keyed hash bugs " \
48
- "(see COR-1923). Use `have_enqueued_sidekiq_job` or `change(Job.jobs, :size)` instead. " \
49
- "Use `.and_call_original` only as an escape hatch.".freeze
57
+ MSG = "Avoid stubbing job/mailer enqueue methods (`perform_async`/`perform_at`/`perform_in`, " \
58
+ "or `Mailer.later`). Stubs bypass `Sidekiq.strict_args!` validation and can hide " \
59
+ "symbol-keyed hash bugs (see COR-1923). Use `have_enqueued_sidekiq_job` or " \
60
+ "`change(Job.jobs, :size)` instead. Use `.and_call_original` only as an escape hatch.".freeze
61
+
62
+ SUBJECT_WRAPPERS = [:allow, :expect, :allow_any_instance_of, :expect_any_instance_of].freeze
50
63
 
51
64
  def_node_matcher :perform_method_stub?, <<~PATTERN
52
65
  (send nil? {:receive :have_received} (sym {:perform_async :perform_at :perform_in}))
53
66
  PATTERN
54
67
 
68
+ def_node_matcher :later_method_stub?, <<~PATTERN
69
+ (send nil? {:receive :have_received} (sym :later))
70
+ PATTERN
71
+
55
72
  def on_send(node)
56
- return unless perform_method_stub?(node)
73
+ return unless perform_method_stub?(node) || mailer_later_stub?(node)
57
74
  return if chained_with_call_original?(node)
58
75
 
59
76
  add_offense(node)
@@ -61,6 +78,26 @@ module RuboCop
61
78
 
62
79
  private
63
80
 
81
+ def mailer_later_stub?(node)
82
+ return false unless later_method_stub?(node)
83
+
84
+ subject = stubbed_subject(node)
85
+ subject&.const_type? && subject.const_name.to_s.end_with?("Mailer")
86
+ end
87
+
88
+ def stubbed_subject(node)
89
+ current = node
90
+ current = current.parent while current.parent&.send_type? && current.parent.receiver == current
91
+
92
+ matcher = current.parent
93
+ return unless matcher&.send_type?
94
+
95
+ wrapper = matcher.receiver
96
+ return unless wrapper&.send_type? && SUBJECT_WRAPPERS.include?(wrapper.method_name)
97
+
98
+ wrapper.first_argument
99
+ end
100
+
64
101
  def chained_with_call_original?(node)
65
102
  current = node
66
103
  loop do
@@ -0,0 +1,156 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Sequra
4
+ # Detects malformed label arguments on `prometheus_exporter` metric calls.
5
+ #
6
+ # `PrometheusExporter::Client::RemoteMetric` takes labels as a positional
7
+ # hash, and the position differs per method:
8
+ #
9
+ # increment(keys = nil, value = 1)
10
+ # decrement(keys = nil, value = 1)
11
+ # observe(value = 1, keys = nil)
12
+ #
13
+ # None of them accept keyword arguments, so `increment(labels: {...})`
14
+ # does not raise. Ruby folds the keyword into the positional `keys` hash
15
+ # and the exporter emits a series carrying a label literally named
16
+ # `labels`, whose value is the stringified inner hash. The result is
17
+ # valid Prometheus exposition text, so the scrape succeeds and the
18
+ # target stays healthy — `sum by (outcome)` simply never matches.
19
+ #
20
+ # Nothing downstream can catch it: the exporter keys its series by the
21
+ # raw hash, `labels_text` interpolates label names without validating
22
+ # them, and `opts: { labels: [...] }` at registration is discarded
23
+ # outright for counters and gauges. There is no label schema anywhere,
24
+ # so a wrong label name is indistinguishable from a new one, and the
25
+ # mistake only surfaces when a query silently returns nothing.
26
+ #
27
+ # @example
28
+ # # bad - folded into a label named "labels"
29
+ # counter.increment(labels: { outcome: :bound })
30
+ #
31
+ # # good
32
+ # counter.increment({ outcome: :bound })
33
+ #
34
+ # # bad - observe takes the value first
35
+ # histogram.observe({ outcome: :ok }, duration)
36
+ #
37
+ # # good
38
+ # histogram.observe(duration, { outcome: :ok })
39
+ #
40
+ # # bad - increment takes the labels first
41
+ # counter.increment(1, { outcome: :bound })
42
+ #
43
+ # # good
44
+ # counter.increment({ outcome: :bound }, 1)
45
+ #
46
+ # # bad - freezes the mistake into a passing spec
47
+ # expect(counter).to receive(:increment).with(labels: { outcome: :bound })
48
+ #
49
+ # # good
50
+ # expect(counter).to receive(:increment).with({ outcome: :bound })
51
+ #
52
+ class PrometheusMetricLabels < Base
53
+ extend AutoCorrector
54
+
55
+ MSG_LABELS_KEYWORD = "Pass Prometheus labels positionally, not as a `labels:` keyword. " \
56
+ "`increment`/`observe`/`decrement` take no keyword arguments, so this " \
57
+ "becomes a single series labelled `labels=\"{...}\"` and by-label " \
58
+ "queries never match. Use `increment({ outcome: :bound })`.".freeze
59
+
60
+ MSG_VALUE_FIRST = "`observe` takes the value first and the labels second: " \
61
+ "`observe(value, { outcome: :ok })`. A labels hash in the first position " \
62
+ "is recorded as the observed value.".freeze
63
+
64
+ MSG_LABELS_FIRST = "`%<method>s` takes the labels first and the value second: " \
65
+ "`%<method>s({ outcome: :ok }, 1)`. This is the reverse of `observe`.".freeze
66
+
67
+ MSG_STUBBED_LABELS = "Stubbing `increment` with a `labels:` keyword asserts a label shape " \
68
+ "prometheus_exporter never produces, so the spec stays green while " \
69
+ "the metric is broken. Match the positional hash instead: " \
70
+ "`.with({ outcome: :bound })`.".freeze
71
+
72
+ LABELS_FIRST_METHODS = [:increment, :decrement].freeze
73
+ VALUE_FIRST_METHODS = [:observe].freeze
74
+ METRIC_METHODS = (LABELS_FIRST_METHODS + VALUE_FIRST_METHODS).freeze
75
+
76
+ # `expect(counter).to receive(:increment).with(...)` and the
77
+ # `have_received` spy equivalent.
78
+ def_node_matcher :stubbed_metric_expectation?, <<~PATTERN
79
+ (send
80
+ (send nil? {:receive :have_received} (sym {:increment :decrement :observe}))
81
+ :with ...)
82
+ PATTERN
83
+
84
+ def on_send(node)
85
+ check_stubbed_labels_keyword(node)
86
+
87
+ return unless node.receiver && METRIC_METHODS.include?(node.method_name)
88
+
89
+ # A `labels:` key is the more specific diagnosis, so when it is
90
+ # present it wins and the positional checks stay quiet.
91
+ return if check_labels_keyword(node)
92
+
93
+ check_argument_order(node)
94
+ end
95
+
96
+ private
97
+
98
+ def check_labels_keyword(node)
99
+ pair = labels_pair(trailing_hash(node))
100
+ return false unless pair
101
+
102
+ register_labels_keyword_offense(node, pair, MSG_LABELS_KEYWORD)
103
+ true
104
+ end
105
+
106
+ def check_stubbed_labels_keyword(node)
107
+ return unless stubbed_metric_expectation?(node)
108
+
109
+ pair = labels_pair(trailing_hash(node))
110
+ return unless pair
111
+
112
+ register_labels_keyword_offense(node, pair, MSG_STUBBED_LABELS)
113
+ end
114
+
115
+ def check_argument_order(node)
116
+ arguments = node.arguments
117
+ return if arguments.empty?
118
+
119
+ if VALUE_FIRST_METHODS.include?(node.method_name)
120
+ add_offense(arguments.first, message: MSG_VALUE_FIRST) if arguments.first.hash_type?
121
+ elsif value_before_labels?(arguments)
122
+ add_offense(node, message: format(MSG_LABELS_FIRST, method: node.method_name))
123
+ end
124
+ end
125
+
126
+ # Only the unambiguous flip: a bare number where the labels belong,
127
+ # followed by the labels hash. Anything less literal than that could
128
+ # be a non-metric `increment` (`Rails.cache`, atomics) and is left
129
+ # alone.
130
+ def value_before_labels?(arguments)
131
+ arguments.size == 2 && arguments.first.numeric_type? && arguments.last.hash_type?
132
+ end
133
+
134
+ def trailing_hash(node)
135
+ last_argument = node.arguments.last
136
+ last_argument if last_argument&.hash_type?
137
+ end
138
+
139
+ def labels_pair(hash)
140
+ return unless hash
141
+
142
+ hash.pairs.find { |pair| pair.key.sym_type? && pair.key.value == :labels }
143
+ end
144
+
145
+ def register_labels_keyword_offense(node, pair, message)
146
+ add_offense(pair, message:) do |corrector|
147
+ # Unwrapping is only safe when `labels:` is the whole hash;
148
+ # with siblings present there is no single value to hoist.
149
+ hash = trailing_hash(node)
150
+ corrector.replace(hash, pair.value.source) if hash.pairs.one?
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -1,5 +1,5 @@
1
1
  module Sequra
2
2
  module Style
3
- VERSION = "1.17.0"
3
+ VERSION = "1.19.0"
4
4
  end
5
5
  end
data/lib/sequra_style.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  require "rubocop"
2
2
  require_relative "rubocop/cop/sequra/async_job_pattern"
3
3
  require_relative "rubocop/cop/sequra/no_sidekiq_perform_stubs"
4
+ require_relative "rubocop/cop/sequra/prometheus_metric_labels"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequra-style
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sequra engineering
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-20 00:00:00.000000000 Z
11
+ date: 2026-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubocop
@@ -142,6 +142,7 @@ files:
142
142
  - docs/decisions/template.md
143
143
  - lib/rubocop/cop/sequra/async_job_pattern.rb
144
144
  - lib/rubocop/cop/sequra/no_sidekiq_perform_stubs.rb
145
+ - lib/rubocop/cop/sequra/prometheus_metric_labels.rb
145
146
  - lib/sequra/style.rb
146
147
  - lib/sequra/style/version.rb
147
148
  - lib/sequra_style.rb