rubocop-vicenzo 0.5.0 → 0.6.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: a18abf5413cb9807bf31adf6adeb6ac47e3fd0e7ec8cb2d47217db9b1617ed40
4
- data.tar.gz: 871b1cb1de89c418517b67919402004ea95044aad2b1a8a0602ceb29953d1093
3
+ metadata.gz: a5b82e09fef23aac921adb4f653bfbdc1117af279326d24339f68db8abe25ffd
4
+ data.tar.gz: f5f4d255aacd3851e4f8b7968b663d22db0b0ed9be99bab01a17df43d819c80f
5
5
  SHA512:
6
- metadata.gz: c629161e84a929d57e0ef7fdec80d17a21742adc08c7f1bafb601356815e99862d987806bfced0e88fb5d1c085af35a4f0e6039dde7cdd664c635a51e9984110
7
- data.tar.gz: 05ddcb76888e50820eda315fa63087c12a01ace730090bd5250f363a2a01b46e8277342ff903d371a5b5d6a0d8c9abcf28023a88caca421c7bfca558acbae110
6
+ metadata.gz: 79d36dbf73503ca38cca4d5ffd77fd84d56cf4b8d320ff389035c359116e36d66d15a3aad4fd3de10d9787fe29f4b4af66b0b669ce98754415fac8ad954106fa
7
+ data.tar.gz: 84055ca6301311a752670e04fa5fcd9646fc38844993428a5f952be17a7384cfa5842b9a2daaa7f3be3242d70e0641b7ba02fdd718fee111f995bd1cd4a8b3e0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.0"
3
+ }
data/CHANGELOG.md CHANGED
@@ -1,4 +1,11 @@
1
- ## [Unreleased]
1
+ # Changelog
2
+
3
+ ## [0.6.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.5.0...v0.6.0) (2026-08-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * add RSpec cops against mutated and derived premises ([#26](https://github.com/bvicenzo/rubocop-vicenzo/issues/26)) ([b138dd0](https://github.com/bvicenzo/rubocop-vicenzo/commit/b138dd00a76e1bcd62e95c1f2c51dca5b4232319))
2
9
 
3
10
  ## [0.5.0] - 2026-06-29
4
11
 
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,100 @@
1
+ # Contributing
2
+
3
+ ## Development setup
4
+
5
+ Run `bin/setup` once after cloning. It installs dependencies and points
6
+ `core.hooksPath` at `.githooks/`, enabling a `commit-msg` hook that enforces
7
+ Conventional Commits locally.
8
+
9
+ Run the test suite and the linter with the default task — the same one CI runs:
10
+
11
+ ```bash
12
+ bundle exec rake
13
+ ```
14
+
15
+ ## Commit messages — Conventional Commits
16
+
17
+ This project follows [Conventional Commits](https://www.conventionalcommits.org/).
18
+ The version bump and the `CHANGELOG.md` are generated automatically from commit
19
+ messages, so the prefix matters:
20
+
21
+ | Type | When to use | Release effect |
22
+ |------|-------------|----------------|
23
+ | `feat:` | New cop or user-facing capability | minor bump |
24
+ | `fix:` | Bug fix in a cop or in the config | patch bump |
25
+ | `feat!:` or a `BREAKING CHANGE:` footer | Backwards-incompatible change | minor bump while `0.x`, major from `1.0` on |
26
+ | `chore:` `ci:` `docs:` `style:` `test:` `refactor:` | Maintenance | no release |
27
+
28
+ Title format: `type: short description` (e.g.
29
+ `feat: add Vicenzo/Style/JsonParseSymbolizeNames cop`).
30
+
31
+ Pull requests are squash-merged, so **the PR title becomes the commit message
32
+ release-please reads**. Get the title right even when the individual commits are
33
+ messy.
34
+
35
+ ## Adding a new cop
36
+
37
+ Generate the cop, its spec and its `config/default.yml` entry with:
38
+
39
+ ```bash
40
+ bundle exec rake 'new_cop[Vicenzo/Department/CopName]'
41
+ ```
42
+
43
+ The generator writes `VersionAdded: '<<next>>'` into `config/default.yml`. Leave
44
+ that placeholder alone — never replace it with a version by hand. The release
45
+ workflow fills it in with the version being released (see below).
46
+
47
+ ## Releasing (release-please)
48
+
49
+ Releases are automated with [release-please](https://github.com/googleapis/release-please):
50
+
51
+ 1. Every push to `master` creates or updates a **release PR** titled
52
+ `chore(master): release X.Y.Z`. It bumps
53
+ `lib/rubocop/vicenzo/version.rb`, `.release-please-manifest.json`,
54
+ `Gemfile.lock` and the version in the `README.md` install snippet, and updates
55
+ `CHANGELOG.md` from the Conventional Commits made since the last release.
56
+ 2. New cops are added with `VersionAdded: '<<next>>'`. The release workflow
57
+ replaces every `<<next>>` in `config/default.yml` with the version being
58
+ released, directly on the release PR — no manual editing needed. This matters
59
+ because `rake docs:generate` reads `VersionAdded` to build the "Version Added"
60
+ column of the documentation site.
61
+ 3. **Merging the release PR** creates the git tag `vX.Y.Z` and a GitHub Release.
62
+ That in turn triggers two things automatically:
63
+ - `.github/workflows/publish.yml` publishes the gem to RubyGems via
64
+ [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC,
65
+ no API key stored in the repository).
66
+ - `.github/workflows/docs.yml` rebuilds and deploys the documentation site to
67
+ GitHub Pages, because `lib/rubocop/vicenzo/version.rb` changed.
68
+ 4. The release PR always reflects **everything on `master` since the last
69
+ release**. Cut releases promptly: merge the release PR before landing
70
+ unrelated new work you don't want included in that release.
71
+
72
+ Nothing about a release is done by hand — there is no `rake release` to run
73
+ locally and no version to edit yourself.
74
+
75
+ ## If the release PR is failing
76
+
77
+ release-please only rebuilds the release branch when a **releasable** commit
78
+ (`feat:` / `fix:`) lands on `master`. Non-releasable fixes (`chore:`, `style:`,
79
+ `ci:`, `docs:`, `test:`) do **not** rebuild it, so the release branch can fall
80
+ behind `master` and its CI can run stale code.
81
+
82
+ When the release PR's CI is red:
83
+
84
+ 1. Push the fix to `master` through a normal PR. **Never** push directly to the
85
+ `release-please--…` branch — release-please overwrites it.
86
+ 2. If those fix commits are non-releasable (so the release PR won't refresh on
87
+ its own), **close the release PR and delete its branch**. On the next push to
88
+ `master`, release-please recreates the release PR from the current `master`
89
+ (now including the fix) and its CI passes.
90
+ 3. Merge the freshly recreated release PR.
91
+
92
+ Never manually rebase or force-push the release-please branch.
93
+
94
+ ## If the gem was not published
95
+
96
+ The tag and the GitHub Release already exist, so publishing is retryable: fix the
97
+ cause and use **Re-run jobs** on the failed `Publish gem` workflow run. An
98
+ authentication failure points at the trusted publisher registration on RubyGems
99
+ (the repository or the workflow filename must match `publish.yml`), not at the
100
+ workflow itself.
data/README.md CHANGED
@@ -6,7 +6,20 @@
6
6
 
7
7
  ## Installation
8
8
 
9
- Install the gem and add to the application's Gemfile by executing:
9
+ Add it to the `development`/`test` group of your `Gemfile`:
10
+
11
+ <!-- x-release-please-start-version -->
12
+ ```ruby
13
+ group :development, :test do
14
+ gem 'rubocop-vicenzo', '~> 0.6.0', require: false
15
+ end
16
+ ```
17
+ <!-- x-release-please-end-version -->
18
+
19
+ > The version above is kept current automatically on every release; pin to
20
+ > whichever version you prefer.
21
+
22
+ Or let Bundler add the latest version for you:
10
23
 
11
24
  ```bash
12
25
  bundle add rubocop-vicenzo --group=development --require=false
@@ -55,7 +68,13 @@ rubocop --plugin rubocop-vicenzo
55
68
 
56
69
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
57
70
 
58
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
71
+ To install this gem onto your local machine, run `bundle exec rake install`.
72
+
73
+ Releases are automated: the version, the `CHANGELOG.md`, the git tag and the push
74
+ to [rubygems.org](https://rubygems.org) are all derived from
75
+ [Conventional Commits](https://www.conventionalcommits.org/) by release-please.
76
+ There is no version to edit and no release command to run by hand — see
77
+ [CONTRIBUTING.md](CONTRIBUTING.md).
59
78
 
60
79
  ### Documentation
61
80
 
@@ -92,7 +111,7 @@ bundle exec rake 'new_cop[Vicenzo/OptionalNamespace/CopName]'
92
111
 
93
112
  ## Contributing
94
113
 
95
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/rubocop-vicenzo. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/rubocop-vicenzo/blob/master/CODE_OF_CONDUCT.md).
114
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bvicenzo/rubocop-vicenzo. Read [CONTRIBUTING.md](CONTRIBUTING.md) first — it covers the commit conventions the release automation depends on. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/bvicenzo/rubocop-vicenzo/blob/master/CODE_OF_CONDUCT.md).
96
115
 
97
116
  ## License
98
117
 
@@ -100,4 +119,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
100
119
 
101
120
  ## Code of Conduct
102
121
 
103
- Everyone interacting in the Rubocop::Vicenzo project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/rubocop-vicenzo/blob/master/CODE_OF_CONDUCT.md).
122
+ Everyone interacting in the Rubocop::Vicenzo project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/bvicenzo/rubocop-vicenzo/blob/master/CODE_OF_CONDUCT.md).
data/config/default.yml CHANGED
@@ -22,6 +22,17 @@ Vicenzo/RSpec/ConditionalInSpec:
22
22
  - '**/spec/factories/**/*'
23
23
  VersionAdded: '0.4.0'
24
24
 
25
+ Vicenzo/RSpec/DerivedPremises:
26
+ Description: 'Do not derive a premise from another premise; declare the complete value where it is needed.'
27
+ Enabled: true
28
+ Severity: warning
29
+ Include:
30
+ - '**/spec/**/*_spec.rb'
31
+ Exclude:
32
+ - '**/spec/support/**/*'
33
+ - '**/spec/factories/**/*'
34
+ VersionAdded: '0.6.0'
35
+
25
36
  Vicenzo/RSpec/DynamicExampleGeneration:
26
37
  Description: 'Do not use iteration to dynamically generate example groups or examples.'
27
38
  Enabled: true
@@ -40,6 +51,17 @@ Vicenzo/RSpec/IterationInsideExample:
40
51
  Severity: warning
41
52
  VersionAdded: '0.4.0'
42
53
 
54
+ Vicenzo/RSpec/MutatedPremises:
55
+ Description: 'Do not mutate the premises (let, let_it_be, subject) an example starts from.'
56
+ Enabled: true
57
+ Severity: warning
58
+ Include:
59
+ - '**/spec/**/*_spec.rb'
60
+ Exclude:
61
+ - '**/spec/support/**/*'
62
+ - '**/spec/factories/**/*'
63
+ VersionAdded: '0.6.0'
64
+
43
65
  Vicenzo/RSpec/NestedContextImproperStart:
44
66
  Description: 'Check if the nested context does not start as a root one.'
45
67
  Enabled: true
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mixin/premise_tracking'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Vicenzo
8
+ module RSpec
9
+ # Do not derive a premise from another premise.
10
+ #
11
+ # `let(:params) { base_params.merge(age: 10) }` does not mutate anything, but it splits a single premise
12
+ # across two definitions: to know what `params` holds in a context you have to read another `let` — usually
13
+ # one that exists only to be modified. Declare the complete value in the context that needs it.
14
+ #
15
+ # Only premise definitions (`let`, `let_it_be`, `subject`) are inspected, and only when the copy starts from
16
+ # another premise: deriving from a factory or from a literal is untouched.
17
+ #
18
+ # @example
19
+ # # bad
20
+ #
21
+ # let(:base_params) { { name: 'Ada' } }
22
+ # let(:params) { base_params.merge(age: 10) }
23
+ #
24
+ # # good
25
+ #
26
+ # context 'when the age is informed' do
27
+ # let(:params) { { name: 'Ada', age: 10 } }
28
+ # end
29
+ #
30
+ # context 'when the age is unknown' do
31
+ # let(:params) { { name: 'Ada' } }
32
+ # end
33
+ #
34
+ # @example
35
+ # # good — the source is a factory, not another premise
36
+ #
37
+ # let(:params) { attributes_for(:order).merge(value: 10) }
38
+ class DerivedPremises < RuboCop::Cop::RSpec::Base
39
+ include PremiseTracking
40
+
41
+ MSG = 'Do not derive the premise `%<name>s` from `%<source>s`. ' \
42
+ 'Declare the complete value in the context that needs it.'
43
+
44
+ DERIVING_METHODS = %i[merge deep_merge reverse_merge dup clone except slice].freeze
45
+
46
+ def on_block(node)
47
+ return unless example_group?(node) && outermost_example_group?(node)
48
+
49
+ walk_example_group(node, all_premises(node))
50
+ end
51
+
52
+ alias on_numblock on_block
53
+
54
+ private
55
+
56
+ def on_premise(node, premises)
57
+ name = premise_name(node)
58
+
59
+ mutating_calls(node).each do |call|
60
+ next unless DERIVING_METHODS.include?(call.method_name)
61
+
62
+ source = premise_receiver(call, premises)
63
+
64
+ next if source.nil?
65
+
66
+ add_offense(call, message: format(MSG, name:, source:))
67
+ end
68
+ end
69
+
70
+ def on_setup_hook(node, premises); end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -14,7 +14,7 @@ module RuboCop
14
14
  # @example
15
15
  # # bad
16
16
  #
17
- # [:admin, :driver].each do |role|
17
+ # [:admin, :editor].each do |role|
18
18
  # context "when role is #{role}" do
19
19
  # it 'does something' do
20
20
  # ...
@@ -32,8 +32,8 @@ module RuboCop
32
32
  # end
33
33
  # end
34
34
  #
35
- # context 'when role is driver' do
36
- # let(:role) { :driver }
35
+ # context 'when role is editor' do
36
+ # let(:role) { :editor }
37
37
  #
38
38
  # it 'does something' do
39
39
  # ...
@@ -16,27 +16,27 @@ module RuboCop
16
16
  # @example
17
17
  # # bad — expect is called inside the iteration
18
18
  #
19
- # it 'returns vehicle costs general values' do
20
- # response_body[:vehicle_costs].first.each do |attribute, value|
21
- # expect(value).to eq(vehicle_cost.send(attribute).to_s)
19
+ # it 'returns product prices general values' do
20
+ # response_body[:product_prices].first.each do |attribute, value|
21
+ # expect(value).to eq(product_price.send(attribute).to_s)
22
22
  # end
23
23
  # end
24
24
  #
25
25
  # # good — iteration builds data, expect is called once outside
26
26
  #
27
27
  # it 'returns the expected column names' do
28
- # columns = VehicleCost.column_names.map { |column| column.gsub('_centavos', '') }
29
- # expect(response_body[:vehicle_costs].first.keys).to match_array(columns.map(&:to_sym))
28
+ # columns = ProductPrice.column_names.map { |column| column.gsub('_cents', '') }
29
+ # expect(response_body[:product_prices].first.keys).to match_array(columns.map(&:to_sym))
30
30
  # end
31
31
  #
32
32
  # # good — each attribute has an explicit example
33
33
  #
34
34
  # it 'returns the correct name' do
35
- # expect(response_body[:vehicle_costs].first[:name]).to eq(vehicle_cost.name)
35
+ # expect(response_body[:product_prices].first[:name]).to eq(product_price.name)
36
36
  # end
37
37
  #
38
38
  # it 'returns the correct value' do
39
- # expect(response_body[:vehicle_costs].first[:value]).to eq(vehicle_cost.value.to_s)
39
+ # expect(response_body[:product_prices].first[:value]).to eq(product_price.value.to_s)
40
40
  # end
41
41
  class IterationInsideExample < RuboCop::Cop::RSpec::Base
42
42
  MSG = 'Do not call `expect` inside an iteration. ' \
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module RSpec
7
+ # Shared helpers to walk example groups keeping track of the premises
8
+ # (`let`, `let!`, `let_it_be`, `subject`) visible at each level, and to
9
+ # resolve which premise a call chain starts from.
10
+ module PremiseTracking
11
+ extend RuboCop::AST::NodePattern::Macros
12
+
13
+ # Brings `Helpers` and `Subjects` into this module's constant lookup, so the patterns below resolve the
14
+ # same RSpec DSL names the cops do.
15
+ include RuboCop::RSpec::Language
16
+
17
+ # Hooks that build the state an example starts from. `after` is left
18
+ # out on purpose: mutating there is teardown, not a premise.
19
+ SETUP_HOOKS = %i[before around prepend_before append_before].freeze
20
+
21
+ # @!method premise_name(node)
22
+ def_node_matcher :premise_name, <<~PATTERN
23
+ (any_block
24
+ (send nil?
25
+ {
26
+ {#Helpers.all :let_it_be :let_it_be!} ({str sym} $_)
27
+ | #Subjects.all (sym $_)
28
+ | $#Subjects.all
29
+ }
30
+ ...
31
+ )
32
+ ...
33
+ )
34
+ PATTERN
35
+
36
+ # @!method root_receiver_name(node)
37
+ # Name of the local call a chain starts from, e.g. `:order`
38
+ # for `order.shipment.update!`.
39
+ def_node_matcher :root_receiver_name, <<~PATTERN
40
+ (send nil? $_)
41
+ PATTERN
42
+
43
+ private
44
+
45
+ def walk_example_group(group, premises)
46
+ each_child_block(group) do |child|
47
+ if example_group?(child)
48
+ walk_example_group(child, premises)
49
+ elsif premise?(child)
50
+ on_premise(child, premises)
51
+ elsif setup_hook?(child)
52
+ on_setup_hook(child, premises)
53
+ end
54
+ end
55
+ end
56
+
57
+ # Every premise in the file, not only the ones an ancestor group declares. A premise defined in a child
58
+ # group is still the premise a definition above reads — the "abstract let" shape — and that is the hardest
59
+ # one to follow when reading.
60
+ def all_premises(root)
61
+ names = Set.new
62
+
63
+ names << premise_name(root) if premise?(root)
64
+ root.each_descendant(:block, :numblock) { |node| names << premise_name(node) if premise?(node) }
65
+
66
+ names
67
+ end
68
+
69
+ def each_child_block(group)
70
+ body = group.body
71
+
72
+ return if body.nil?
73
+
74
+ children = body.begin_type? ? body.children : [body]
75
+
76
+ children.each { |child| yield(child) if child.any_block_type? }
77
+ end
78
+
79
+ def outermost_example_group?(node)
80
+ node.each_ancestor(:block, :numblock).none? { |ancestor| example_group?(ancestor) }
81
+ end
82
+
83
+ def premise?(node) = let?(node) || let_it_be?(node) || subject?(node)
84
+
85
+ # @!method let_it_be?(node)
86
+ def_node_matcher :let_it_be?, <<~PATTERN
87
+ (any_block (send nil? {:let_it_be :let_it_be!} ...) ...)
88
+ PATTERN
89
+
90
+ def setup_hook?(node) = hook?(node) && SETUP_HOOKS.include?(node.method_name)
91
+
92
+ def premise_receiver(node, premises)
93
+ receiver = node.receiver
94
+
95
+ while receiver&.send_type?
96
+ name = root_receiver_name(receiver)
97
+
98
+ return name if name && premises.include?(name)
99
+
100
+ receiver = receiver.receiver
101
+ end
102
+
103
+ nil
104
+ end
105
+
106
+ def mutating_calls(node)
107
+ body = node.body
108
+
109
+ return [] if body.nil?
110
+
111
+ body.each_node(:send).to_a
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mixin/premise_tracking'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Vicenzo
8
+ module RSpec
9
+ # Do not mutate the premises of an example.
10
+ #
11
+ # A `let`, `let_it_be` or `subject` is the premise a reader assumes true when reading the file. Mutating it
12
+ # somewhere else breaks that in two ways:
13
+ #
14
+ # 1. The file lies. You read `let(:params) { { name: 'Ada' } }`, run it, and the value is different — the
15
+ # explanation is a `before` dozens of lines away.
16
+ # 2. It breeds flakiness. `let` is lazy, so whichever example materialises it first decides the final state.
17
+ # A context that touches the premises in another order silently changes the result.
18
+ #
19
+ # Persisting changes while building a premise is flagged wherever it happens inside the definition, including
20
+ # inline forms such as `tap`, because a factory trait or transient is always available instead. Collection
21
+ # mutations and attribute writers are flagged when they change a premise declared in an ancestor group, so
22
+ # building a local value inside the definition itself stays allowed.
23
+ #
24
+ # Examples are not inspected: there the mutation usually *is* the behaviour under test.
25
+ #
26
+ # @example
27
+ # # bad — persisting while building the premise
28
+ #
29
+ # let(:order) { create(:order).tap { |record| record.update!(status: :shipped) } }
30
+ #
31
+ # # bad — the same laziness spelled out
32
+ #
33
+ # let(:order) do
34
+ # order = create(:order)
35
+ # order.update_column(:status, :shipped)
36
+ # order
37
+ # end
38
+ #
39
+ # # good — the factory owns it
40
+ #
41
+ # let(:order) { create(:order, :shipped) }
42
+ #
43
+ # @example
44
+ # # bad — the hook rewrites the premise
45
+ #
46
+ # let(:params) { { name: 'Ada' } }
47
+ # before { params[:age] = 10 }
48
+ #
49
+ # # bad — patching a record the premise built
50
+ #
51
+ # before { order.shipment.update!(carrier:) }
52
+ #
53
+ # # good — each context declares its complete premise
54
+ #
55
+ # context 'when the age is informed' do
56
+ # let(:params) { { name: 'Ada', age: 10 } }
57
+ # end
58
+ #
59
+ # # good — creation order lets the production code do the wiring
60
+ #
61
+ # before do
62
+ # carrier
63
+ # order
64
+ # end
65
+ #
66
+ # @example
67
+ # # good — local builder, the value is born and dies inside the premise
68
+ #
69
+ # let(:csv_content) { CSV.generate { |csv| csv << header } }
70
+ class MutatedPremises < RuboCop::Cop::RSpec::Base
71
+ include PremiseTracking
72
+
73
+ MSG_MUTATION = 'Do not mutate the premise `%<name>s`. Declare the final state where it is needed — ' \
74
+ 'factory trait, factory transient, or creation order.'
75
+ MSG_PERSISTENCE = 'Do not persist changes while building `%<name>s`. Move it to a factory ' \
76
+ 'trait/transient, or declare the final state in the context that needs it.'
77
+
78
+ PERSISTENCE_METHODS = %i[
79
+ update update! update_attribute update_column update_columns save save! destroy destroy! touch
80
+ increment! decrement! toggle!
81
+ ].freeze
82
+
83
+ COLLECTION_METHODS = %i[<< push concat unshift store merge! deep_merge! clear delete].freeze
84
+
85
+ def on_block(node)
86
+ return unless example_group?(node) && outermost_example_group?(node)
87
+
88
+ walk_example_group(node, all_premises(node))
89
+ end
90
+
91
+ alias on_numblock on_block
92
+
93
+ private
94
+
95
+ def on_premise(node, premises)
96
+ name = premise_name(node)
97
+
98
+ mutating_calls(node).each do |call|
99
+ if persisting?(call)
100
+ add_offense(call, message: format(MSG_PERSISTENCE, name:))
101
+ else
102
+ check_premise_mutation(call, premises)
103
+ end
104
+ end
105
+ end
106
+
107
+ def on_setup_hook(node, premises)
108
+ mutating_calls(node).each do |call|
109
+ next unless persisting?(call) || mutating_collection?(call)
110
+
111
+ check_premise_mutation(call, premises)
112
+ end
113
+ end
114
+
115
+ def check_premise_mutation(call, premises)
116
+ return unless persisting?(call) || mutating_collection?(call)
117
+
118
+ mutated_premise = premise_receiver(call, premises)
119
+
120
+ return if mutated_premise.nil?
121
+
122
+ add_offense(call, message: format(MSG_MUTATION, name: mutated_premise))
123
+ end
124
+
125
+ # `FileUtils.touch(path)` and friends act on a class, never on a premise.
126
+ def persisting?(call)
127
+ PERSISTENCE_METHODS.include?(call.method_name) && !call.receiver&.const_type?
128
+ end
129
+
130
+ def mutating_collection?(call)
131
+ COLLECTION_METHODS.include?(call.method_name) || assignment?(call)
132
+ end
133
+
134
+ # Covers both `params[:age] = 10` (`:[]=`) and `record.status = :done` (`:status=`).
135
+ def assignment?(call) = call.assignment_method? && !call.receiver.nil?
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'vicenzo/rspec/conditional_in_spec'
4
+ require_relative 'vicenzo/rspec/derived_premises'
4
5
  require_relative 'vicenzo/rspec/dynamic_example_generation'
5
6
  require_relative 'vicenzo/rspec/inconsistent_sibling_structure'
6
7
  require_relative 'vicenzo/rspec/iteration_inside_example'
8
+ require_relative 'vicenzo/rspec/mutated_premises'
7
9
  require_relative 'vicenzo/rspec/nested_context_improper_start'
8
10
  require_relative 'vicenzo/rspec/nested_let_redefinition'
9
11
  require_relative 'vicenzo/rspec/nested_subject_redefinition'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Vicenzo
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "bump-minor-pre-major": true,
4
+ "include-component-in-tag": false,
5
+ "packages": {
6
+ ".": {
7
+ "release-type": "ruby",
8
+ "package-name": "rubocop-vicenzo",
9
+ "version-file": "lib/rubocop/vicenzo/version.rb",
10
+ "extra-files": [
11
+ { "type": "generic", "path": "README.md" }
12
+ ]
13
+ }
14
+ }
15
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-vicenzo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Vicenzo
@@ -59,10 +59,12 @@ executables: []
59
59
  extensions: []
60
60
  extra_rdoc_files: []
61
61
  files:
62
+ - ".release-please-manifest.json"
62
63
  - ".rspec"
63
64
  - ".rubocop.yml"
64
65
  - CHANGELOG.md
65
66
  - CODE_OF_CONDUCT.md
67
+ - CONTRIBUTING.md
66
68
  - LICENSE.txt
67
69
  - README.md
68
70
  - Rakefile
@@ -74,10 +76,13 @@ files:
74
76
  - lib/rubocop/cop/vicenzo/layout/multiline_method_call_line_breaks.rb
75
77
  - lib/rubocop/cop/vicenzo/rails/enum_inclusion_of_validation.rb
76
78
  - lib/rubocop/cop/vicenzo/rspec/conditional_in_spec.rb
79
+ - lib/rubocop/cop/vicenzo/rspec/derived_premises.rb
77
80
  - lib/rubocop/cop/vicenzo/rspec/dynamic_example_generation.rb
78
81
  - lib/rubocop/cop/vicenzo/rspec/inconsistent_sibling_structure.rb
79
82
  - lib/rubocop/cop/vicenzo/rspec/iteration_inside_example.rb
80
83
  - lib/rubocop/cop/vicenzo/rspec/leaky_definition.rb
84
+ - lib/rubocop/cop/vicenzo/rspec/mixin/premise_tracking.rb
85
+ - lib/rubocop/cop/vicenzo/rspec/mutated_premises.rb
81
86
  - lib/rubocop/cop/vicenzo/rspec/nested_context_improper_start.rb
82
87
  - lib/rubocop/cop/vicenzo/rspec/nested_let_redefinition.rb
83
88
  - lib/rubocop/cop/vicenzo/rspec/nested_subject_redefinition.rb
@@ -88,7 +93,7 @@ files:
88
93
  - lib/rubocop/vicenzo/plugin.rb
89
94
  - lib/rubocop/vicenzo/version.rb
90
95
  - rakelib/docs.rake
91
- - rakelib/release.rake
96
+ - release-please-config.json
92
97
  - sig/rubocop/vicenzo.rbs
93
98
  homepage: https://github.com/bvicenzo/rubocop-vicenzo
94
99
  licenses:
@@ -114,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
114
119
  - !ruby/object:Gem::Version
115
120
  version: '0'
116
121
  requirements: []
117
- rubygems_version: 4.0.3
122
+ rubygems_version: 4.0.16
118
123
  specification_version: 4
119
124
  summary: Cops of Bruno Vicenzo
120
125
  test_files: []
data/rakelib/release.rake DELETED
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'date'
4
-
5
- desc 'Prepare release: update config, version file, and changelog'
6
- task :cut_release, [:version] do |_t, args|
7
- version = args[:version]
8
-
9
- # 1. Validation
10
- abort 'Error: You must provide a version. Example: rake cut_release[0.3.0]' unless version
11
-
12
- config_file = 'config/default.yml'
13
- version_file = 'lib/rubocop/vicenzo/version.rb'
14
- changelog_file = 'CHANGELOG.md'
15
-
16
- # Check if files exist
17
- [config_file, version_file, changelog_file].each do |file|
18
- abort "Error: File not found at #{file}" unless File.exist?(file)
19
- end
20
-
21
- puts "✂️ Cutting release for version #{version}..."
22
-
23
- # -------------------------------------------------------
24
- # 2. Update config/default.yml (<<next>> -> version)
25
- # -------------------------------------------------------
26
- config_content = File.read(config_file)
27
- if config_content.include?("'<<next>>'")
28
- updated_config = config_content.gsub("'<<next>>'", "'#{version}'")
29
- File.write(config_file, updated_config)
30
- puts " ✅ Updated 'VersionAdded' in #{config_file}"
31
- else
32
- puts " ⚠️ No '<<next>>' found in #{config_file} (skipping)"
33
- end
34
-
35
- # -------------------------------------------------------
36
- # 3. Update lib/rubocop/vicenzo/version.rb
37
- # -------------------------------------------------------
38
- version_content = File.read(version_file)
39
- # Regex looks for: VERSION = '...' or VERSION = "..."
40
- if version_content.match?(/VERSION\s*=\s*['"](.+)['"]/)
41
- updated_version = version_content.gsub(/VERSION\s*=\s*['"](.+)['"]/, "VERSION = '#{version}'")
42
- File.write(version_file, updated_version)
43
- puts " ✅ Updated VERSION constant in #{version_file}"
44
- else
45
- puts " ❌ Could not find VERSION constant in #{version_file}"
46
- end
47
-
48
- # -------------------------------------------------------
49
- # 4. Update CHANGELOG.md
50
- # -------------------------------------------------------
51
- changelog_content = File.read(changelog_file)
52
- unreleased_header = '## [Unreleased]'
53
- date = Date.today.to_s # YYYY-MM-DD
54
-
55
- # We replace "## [Unreleased]" with:
56
- # ## [Unreleased]
57
- #
58
- # ## [version] - date
59
- #
60
- # This pushes the existing unreleased items down under the new version header.
61
- new_entry_header = "#{unreleased_header}\n\n## [#{version}] - #{date}"
62
-
63
- if changelog_content.include?(unreleased_header)
64
- # We use 'sub' to replace only the first occurrence (the top one)
65
- updated_changelog = changelog_content.sub(unreleased_header, new_entry_header)
66
- File.write(changelog_file, updated_changelog)
67
- puts " ✅ Updated #{changelog_file} (moved items to [#{version}])"
68
- else
69
- puts " ❌ Could not find '#{unreleased_header}' in #{changelog_file}"
70
- end
71
-
72
- puts "\n🎉 Release preparation complete! Don't forget to commit."
73
- end