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 +4 -4
- data/CHANGELOG.md +14 -0
- data/Gemfile.lock +1 -1
- data/default.yml +11 -0
- data/lib/rubocop/cop/sequra/no_sidekiq_perform_stubs.rb +44 -7
- data/lib/rubocop/cop/sequra/prometheus_metric_labels.rb +156 -0
- data/lib/sequra/style/version.rb +1 -1
- data/lib/sequra_style.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47e2880848be7928ef68435e320055b4d2003f4b321d172094fa77f123f5139e
|
|
4
|
+
data.tar.gz: d8ef008a1b7ba0477c45a19d52eb965d86a994fe5c7c203c67ecc2941f2ac798
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
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
|
|
47
|
-
"Stubs bypass `Sidekiq.strict_args!` validation and can hide
|
|
48
|
-
"(see COR-1923). Use `have_enqueued_sidekiq_job` or
|
|
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
|
data/lib/sequra/style/version.rb
CHANGED
data/lib/sequra_style.rb
CHANGED
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.
|
|
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-
|
|
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
|