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 +4 -4
- data/.release-please-manifest.json +3 -0
- data/CHANGELOG.md +8 -1
- data/CONTRIBUTING.md +100 -0
- data/README.md +23 -4
- data/config/default.yml +22 -0
- data/lib/rubocop/cop/vicenzo/rspec/derived_premises.rb +75 -0
- data/lib/rubocop/cop/vicenzo/rspec/dynamic_example_generation.rb +3 -3
- data/lib/rubocop/cop/vicenzo/rspec/iteration_inside_example.rb +7 -7
- data/lib/rubocop/cop/vicenzo/rspec/mixin/premise_tracking.rb +117 -0
- data/lib/rubocop/cop/vicenzo/rspec/mutated_premises.rb +140 -0
- data/lib/rubocop/cop/vicenzo_cops.rb +2 -0
- data/lib/rubocop/vicenzo/version.rb +1 -1
- data/release-please-config.json +15 -0
- metadata +8 -3
- data/rakelib/release.rake +0 -73
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5b82e09fef23aac921adb4f653bfbdc1117af279326d24339f68db8abe25ffd
|
|
4
|
+
data.tar.gz: f5f4d255aacd3851e4f8b7968b663d22db0b0ed9be99bab01a17df43d819c80f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79d36dbf73503ca38cca4d5ffd77fd84d56cf4b8d320ff389035c359116e36d66d15a3aad4fd3de10d9787fe29f4b4af66b0b669ce98754415fac8ad954106fa
|
|
7
|
+
data.tar.gz: 84055ca6301311a752670e04fa5fcd9646fc38844993428a5f952be17a7384cfa5842b9a2daaa7f3be3242d70e0641b7ba02fdd718fee111f995bd1cd4a8b3e0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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`.
|
|
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/
|
|
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/
|
|
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, :
|
|
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
|
|
36
|
-
# let(:role) { :
|
|
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
|
|
20
|
-
# response_body[:
|
|
21
|
-
# expect(value).to eq(
|
|
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 =
|
|
29
|
-
# expect(response_body[:
|
|
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[:
|
|
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[:
|
|
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'
|
|
@@ -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.
|
|
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
|
-
-
|
|
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.
|
|
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
|