rubocop-rspec-guide 0.2.1 → 0.4.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -6
  3. data/.yardopts +9 -0
  4. data/CHANGELOG.md +92 -0
  5. data/CONTRIBUTING.md +358 -0
  6. data/INTEGRATION_TESTING.md +324 -0
  7. data/README.md +443 -16
  8. data/Rakefile +49 -0
  9. data/benchmark/README.md +349 -0
  10. data/benchmark/baseline_v0.3.1.txt +67 -0
  11. data/benchmark/baseline_v0.4.0.txt +167 -0
  12. data/benchmark/benchmark_helper.rb +92 -0
  13. data/benchmark/compare_versions.rb +136 -0
  14. data/benchmark/cops_benchmark.rb +428 -0
  15. data/benchmark/cops_performance.rb +109 -0
  16. data/benchmark/quick_comparison.rb +58 -0
  17. data/benchmark/quick_invariant_bench.rb +52 -0
  18. data/benchmark/rspec_base_integration.rb +86 -0
  19. data/benchmark/save_baseline.rb +18 -0
  20. data/benchmark/scalability_benchmark.rb +181 -0
  21. data/config/default.yml +43 -2
  22. data/config/obsoletion.yml +6 -0
  23. data/lib/rubocop/cop/factory_bot_guide/dynamic_attribute_evaluation.rb +193 -0
  24. data/lib/rubocop/cop/factory_bot_guide/dynamic_attributes_for_time_and_random.rb +10 -106
  25. data/lib/rubocop/cop/rspec_guide/characteristics_and_contexts.rb +13 -78
  26. data/lib/rubocop/cop/rspec_guide/context_setup.rb +81 -30
  27. data/lib/rubocop/cop/rspec_guide/duplicate_before_hooks.rb +89 -22
  28. data/lib/rubocop/cop/rspec_guide/duplicate_let_values.rb +91 -22
  29. data/lib/rubocop/cop/rspec_guide/happy_path_first.rb +52 -21
  30. data/lib/rubocop/cop/rspec_guide/invariant_examples.rb +60 -19
  31. data/lib/rubocop/cop/rspec_guide/minimum_behavioral_coverage.rb +165 -0
  32. data/lib/rubocop/rspec/guide/inject.rb +26 -0
  33. data/lib/rubocop/rspec/guide/plugin.rb +45 -0
  34. data/lib/rubocop/rspec/guide/version.rb +1 -1
  35. data/lib/rubocop-rspec-guide.rb +4 -0
  36. metadata +49 -1
@@ -0,0 +1,58 @@
1
+ require "benchmark/ips"
2
+ require_relative "../lib/rubocop-rspec-guide"
3
+
4
+ SAMPLE = <<~RUBY
5
+ describe 'Example' do
6
+ context 'case A' do
7
+ let(:value) { 42 }
8
+ before { setup }
9
+ it 'works' do
10
+ expect(true).to be true
11
+ end
12
+ end
13
+ context 'case B' do
14
+ let(:value) { 42 }
15
+ before { setup }
16
+ it 'works' do
17
+ expect(true).to be true
18
+ end
19
+ end
20
+ end
21
+ RUBY
22
+
23
+ source = RuboCop::ProcessedSource.new(SAMPLE, RUBY_VERSION.to_f)
24
+ config = RuboCop::Config.new({
25
+ "RSpec" => {
26
+ "Language" => {
27
+ "ExampleGroups" => {"Regular" => %w[describe context], "Skipped" => [], "Focused" => []},
28
+ "Examples" => {"Regular" => %w[it], "Focused" => [], "Skipped" => [], "Pending" => []},
29
+ "Helpers" => %w[let let!],
30
+ "Hooks" => %w[before after],
31
+ "Subjects" => %w[subject]
32
+ }
33
+ }
34
+ })
35
+
36
+ RuboCop::RSpec::Language.config = config['RSpec']['Language']
37
+
38
+ puts "Quick Performance Check (Optimized Version)"
39
+ puts "=" * 60
40
+
41
+ Benchmark.ips do |x|
42
+ x.config(time: 2, warmup: 1)
43
+
44
+ [
45
+ RuboCop::Cop::RSpecGuide::HappyPathFirst,
46
+ RuboCop::Cop::RSpecGuide::DuplicateLetValues,
47
+ RuboCop::Cop::RSpecGuide::DuplicateBeforeHooks,
48
+ RuboCop::Cop::RSpecGuide::InvariantExamples
49
+ ].each do |cop_class|
50
+ x.report(cop_class.badge.to_s) do
51
+ cop = cop_class.new(config)
52
+ commissioner = RuboCop::Cop::Commissioner.new([cop], [], raise_error: false)
53
+ commissioner.investigate(source)
54
+ end
55
+ end
56
+
57
+ x.compare!
58
+ end
@@ -0,0 +1,52 @@
1
+ require "benchmark/ips"
2
+ require_relative "../lib/rubocop-rspec-guide"
3
+
4
+ SAMPLE = <<~RUBY
5
+ describe 'Validator' do
6
+ context 'with valid data' do
7
+ it 'responds to valid?' do
8
+ expect(subject).to respond_to(:valid?)
9
+ end
10
+ end
11
+ context 'with invalid data' do
12
+ it 'responds to valid?' do
13
+ expect(subject).to respond_to(:valid?)
14
+ end
15
+ end
16
+ context 'with edge case' do
17
+ it 'responds to valid?' do
18
+ expect(subject).to respond_to(:valid?)
19
+ end
20
+ end
21
+ end
22
+ RUBY
23
+
24
+ source = RuboCop::ProcessedSource.new(SAMPLE, RUBY_VERSION.to_f)
25
+ config = RuboCop::Config.new({
26
+ "RSpec" => {
27
+ "Language" => {
28
+ "ExampleGroups" => {"Regular" => %w[describe context], "Skipped" => [], "Focused" => []},
29
+ "Examples" => {"Regular" => %w[it], "Focused" => [], "Skipped" => [], "Pending" => []},
30
+ "Helpers" => %w[let],
31
+ "Hooks" => %w[before],
32
+ "Subjects" => %w[subject]
33
+ }
34
+ },
35
+ "RSpecGuide/InvariantExamples" => {"MinLeafContexts" => 3}
36
+ })
37
+
38
+ RuboCop::RSpec::Language.config = config['RSpec']['Language']
39
+
40
+ puts "Benchmarking InvariantExamples (optimized with local matcher)"
41
+ puts "Expected baseline: ~1504 i/s, Previous (slow): ~854 i/s"
42
+ puts ""
43
+
44
+ Benchmark.ips do |x|
45
+ x.config(time: 2, warmup: 1)
46
+
47
+ x.report("InvariantExamples") do
48
+ cop = RuboCop::Cop::RSpecGuide::InvariantExamples.new(config)
49
+ commissioner = RuboCop::Cop::Commissioner.new([cop], [], raise_error: false)
50
+ commissioner.investigate(source)
51
+ end
52
+ end
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "benchmark/ips"
5
+ require "rubocop"
6
+ require_relative "../lib/rubocop-rspec-guide"
7
+
8
+ # Create a sample RSpec file to analyze
9
+ SAMPLE_CODE = <<~RUBY
10
+ describe 'UserService' do
11
+ context 'when user is admin' do
12
+ let(:user) { create(:user, :admin) }
13
+ let(:service) { UserService.new(user) }
14
+
15
+ before { setup_admin_permissions }
16
+
17
+ it 'has admin access' do
18
+ expect(service.admin?).to be true
19
+ end
20
+
21
+ it 'can modify settings' do
22
+ expect(service.can_modify_settings?).to be true
23
+ end
24
+ end
25
+
26
+ context 'when user is regular' do
27
+ let(:user) { create(:user) }
28
+ let(:service) { UserService.new(user) }
29
+
30
+ before { setup_admin_permissions }
31
+
32
+ it 'does not have admin access' do
33
+ expect(service.admin?).to be false
34
+ end
35
+
36
+ it 'cannot modify settings' do
37
+ expect(service.can_modify_settings?).to be false
38
+ end
39
+ end
40
+ end
41
+ RUBY
42
+
43
+ # Configure RuboCop
44
+ config = RuboCop::ConfigStore.new
45
+ config_hash = {
46
+ "AllCops" => { "TargetRubyVersion" => 3.0 },
47
+ "RSpecGuide/MinimumBehavioralCoverage" => { "Enabled" => true },
48
+ "RSpecGuide/HappyPathFirst" => { "Enabled" => true },
49
+ "RSpecGuide/ContextSetup" => { "Enabled" => true },
50
+ "RSpecGuide/DuplicateLetValues" => { "Enabled" => true },
51
+ "RSpecGuide/DuplicateBeforeHooks" => { "Enabled" => true },
52
+ "RSpecGuide/InvariantExamples" => { "Enabled" => true }
53
+ }
54
+
55
+ puts "=" * 80
56
+ puts "RuboCop::Cop::RSpec::Base Integration Benchmark"
57
+ puts "=" * 80
58
+ puts "\nSample code size: #{SAMPLE_CODE.lines.count} lines"
59
+ puts "Cops enabled: 6 RSpec cops"
60
+ puts "\n"
61
+
62
+ # Run benchmark
63
+ Benchmark.ips do |x|
64
+ x.config(time: 10, warmup: 3)
65
+
66
+ x.report("Process sample RSpec file") do
67
+ runner = RuboCop::Runner.new(
68
+ { format: "quiet" },
69
+ RuboCop::ConfigStore.new
70
+ )
71
+
72
+ # Create a temporary file
73
+ require "tempfile"
74
+ file = Tempfile.new(["benchmark_spec", ".rb"])
75
+ begin
76
+ file.write(SAMPLE_CODE)
77
+ file.flush
78
+ runner.run([file.path])
79
+ ensure
80
+ file.close
81
+ file.unlink
82
+ end
83
+ end
84
+
85
+ x.compare!
86
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # This script runs benchmarks and saves them as a baseline for future comparison
5
+ require_relative "benchmark_helper"
6
+
7
+ version = ENV["VERSION"] || "unknown"
8
+ output_file = "benchmark/baseline_v#{version}.txt"
9
+
10
+ puts "Saving baseline benchmark for version #{version}"
11
+ puts "Output file: #{output_file}"
12
+ puts ""
13
+
14
+ # Run cops_benchmark and save output
15
+ system("ruby benchmark/cops_benchmark.rb > #{output_file} 2>&1")
16
+
17
+ puts ""
18
+ puts "Baseline saved to #{output_file}"
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "benchmark_helper"
5
+
6
+ # Benchmark cops with different file sizes
7
+ puts "=" * 80
8
+ puts "RuboCop RSpec Guide - Scalability Benchmark"
9
+ puts "Testing how cops perform with different file sizes"
10
+ puts "=" * 80
11
+ puts ""
12
+
13
+ # Configure benchmark timing based on environment
14
+ if ENV["FULL_BENCHMARK"]
15
+ WARMUP_TIME = 2
16
+ MEASUREMENT_TIME = 5
17
+ puts "Mode: FULL (accurate measurements)"
18
+ else
19
+ WARMUP_TIME = 1
20
+ MEASUREMENT_TIME = 2
21
+ puts "Mode: QUICK (fast feedback)"
22
+ end
23
+ puts ""
24
+
25
+ # Test with increasing number of contexts
26
+ Benchmark.ips do |x|
27
+ x.config(time: MEASUREMENT_TIME, warmup: WARMUP_TIME)
28
+
29
+ [5, 10, 20, 50].each do |contexts_count|
30
+ source = BenchmarkHelper.generate_context_code(
31
+ contexts_count: contexts_count,
32
+ examples_per_context: 3
33
+ )
34
+
35
+ x.report("MinimumBehavioralCoverage - #{contexts_count} contexts") do
36
+ BenchmarkHelper.run_cop(
37
+ RuboCop::Cop::RSpecGuide::MinimumBehavioralCoverage,
38
+ source
39
+ )
40
+ end
41
+ end
42
+
43
+ x.compare!
44
+ end
45
+
46
+ puts "\n"
47
+
48
+ # Test with increasing number of examples
49
+ Benchmark.ips do |x|
50
+ x.config(time: MEASUREMENT_TIME, warmup: WARMUP_TIME)
51
+
52
+ [10, 25, 50, 100].each do |examples_count|
53
+ source = BenchmarkHelper.generate_rspec_code(examples_count: examples_count)
54
+
55
+ x.report("ContextSetup - #{examples_count} examples") do
56
+ BenchmarkHelper.run_cop(
57
+ RuboCop::Cop::RSpecGuide::ContextSetup,
58
+ source
59
+ )
60
+ end
61
+ end
62
+
63
+ x.compare!
64
+ end
65
+
66
+ puts "\n"
67
+
68
+ # Test DuplicateLetValues with nested contexts
69
+ Benchmark.ips do |x|
70
+ x.config(time: MEASUREMENT_TIME, warmup: WARMUP_TIME)
71
+
72
+ [3, 5, 10, 15].each do |nesting_level|
73
+ contexts = (1..nesting_level).map do |i|
74
+ indent = " " * i
75
+ <<~RUBY
76
+ #{indent}context "level #{i}" do
77
+ #{indent} let(:value) { #{i} }
78
+ #{indent}
79
+ #{indent} it "works" do
80
+ #{indent} expect(value).to eq(#{i})
81
+ #{indent} end
82
+ RUBY
83
+ end
84
+
85
+ source = <<~RUBY
86
+ RSpec.describe MyClass do
87
+ #{contexts.join("\n")}
88
+ #{" end\n" * nesting_level}
89
+ end
90
+ RUBY
91
+
92
+ x.report("DuplicateLetValues - #{nesting_level} nesting levels") do
93
+ BenchmarkHelper.run_cop(
94
+ RuboCop::Cop::RSpecGuide::DuplicateLetValues,
95
+ source
96
+ )
97
+ end
98
+ end
99
+
100
+ x.compare!
101
+ end
102
+
103
+ puts "\n"
104
+
105
+ # Test InvariantExamples with many duplicates
106
+ Benchmark.ips do |x|
107
+ x.config(time: MEASUREMENT_TIME, warmup: WARMUP_TIME)
108
+
109
+ [2, 5, 10, 15].each do |duplicates_count|
110
+ contexts = (1..duplicates_count).map do |i|
111
+ <<~RUBY
112
+ context "scenario #{i}" do
113
+ it "validates input" do
114
+ expect(subject).to respond_to(:valid?)
115
+ end
116
+
117
+ it "has specific behavior" do
118
+ expect(subject.call(#{i})).to eq(#{i})
119
+ end
120
+ end
121
+ RUBY
122
+ end
123
+
124
+ source = <<~RUBY
125
+ RSpec.describe MyClass do
126
+ #{contexts.join("\n")}
127
+ end
128
+ RUBY
129
+
130
+ x.report("InvariantExamples - #{duplicates_count} duplicates") do
131
+ BenchmarkHelper.run_cop(
132
+ RuboCop::Cop::RSpecGuide::InvariantExamples,
133
+ source
134
+ )
135
+ end
136
+ end
137
+
138
+ x.compare!
139
+ end
140
+
141
+ puts "\n"
142
+
143
+ # Memory usage test
144
+ require "objspace"
145
+
146
+ puts "=" * 80
147
+ puts "Memory Usage Analysis"
148
+ puts "=" * 80
149
+ puts ""
150
+
151
+ large_source = BenchmarkHelper.generate_context_code(
152
+ contexts_count: 50,
153
+ examples_per_context: 10
154
+ )
155
+
156
+ cops = [
157
+ RuboCop::Cop::RSpecGuide::MinimumBehavioralCoverage,
158
+ RuboCop::Cop::RSpecGuide::HappyPathFirst,
159
+ RuboCop::Cop::RSpecGuide::ContextSetup,
160
+ RuboCop::Cop::RSpecGuide::DuplicateLetValues,
161
+ RuboCop::Cop::RSpecGuide::DuplicateBeforeHooks,
162
+ RuboCop::Cop::RSpecGuide::InvariantExamples,
163
+ RuboCop::Cop::FactoryBotGuide::DynamicAttributeEvaluation
164
+ ]
165
+
166
+ cops.each do |cop_class|
167
+ GC.start
168
+ before = ObjectSpace.memsize_of_all
169
+
170
+ BenchmarkHelper.run_cop(cop_class, large_source)
171
+
172
+ after = ObjectSpace.memsize_of_all
173
+ memory_used = (after - before) / 1024.0 / 1024.0
174
+
175
+ puts "#{cop_class.cop_name}: #{"%.2f" % memory_used} MB"
176
+ end
177
+
178
+ puts "\n"
179
+ puts "=" * 80
180
+ puts "Scalability benchmark completed!"
181
+ puts "=" * 80
data/config/default.yml CHANGED
@@ -1,38 +1,79 @@
1
1
  ---
2
+ # RSpec Language configuration for rubocop-rspec integration
3
+ # This enables RuboCop::Cop::RSpec::Base matchers to work properly
4
+ RSpec:
5
+ Enabled: true
6
+ Language:
7
+ inherit_mode:
8
+ merge:
9
+ - Expectations
10
+ - Helpers
11
+ - Hooks
12
+ - Subjects
13
+ Helpers:
14
+ - let
15
+ - let!
16
+ - let_it_be
17
+ - let_it_be!
18
+
19
+ RSpecGuide/MinimumBehavioralCoverage:
20
+ Description: "Require at least 2 behavioral variations in describe block (2+ contexts OR it-blocks + contexts)"
21
+ Enabled: true
22
+ VersionAdded: "0.3.0"
23
+ StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
24
+
2
25
  RSpecGuide/CharacteristicsAndContexts:
3
- Description: "Require at least 2 contexts in describe block (happy path + edge cases)"
26
+ Description: "[DEPRECATED] Use RSpecGuide/MinimumBehavioralCoverage instead"
4
27
  Enabled: true
28
+ VersionAdded: "0.1.0"
29
+ VersionChanged: "0.3.0"
5
30
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
6
31
 
7
32
  RSpecGuide/DuplicateLetValues:
8
33
  Description: "Detect duplicate let values in sibling contexts (ERROR if in all, WARNING if partial)"
9
34
  Enabled: true
10
35
  WarnOnPartialDuplicates: true
36
+ VersionAdded: "0.1.0"
37
+ VersionChanged: "0.2.2"
11
38
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
12
39
 
13
40
  RSpecGuide/DuplicateBeforeHooks:
14
41
  Description: "Detect duplicate before hooks in sibling contexts (ERROR if in all, WARNING if partial)"
15
42
  Enabled: true
16
43
  WarnOnPartialDuplicates: true
44
+ VersionAdded: "0.1.0"
17
45
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
18
46
 
19
47
  RSpecGuide/InvariantExamples:
20
48
  Description: "Invariant examples should be in leaf contexts or extracted to shared_examples"
21
49
  Enabled: true
22
50
  MinLeafContexts: 3
51
+ VersionAdded: "0.1.0"
23
52
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
24
53
 
25
54
  RSpecGuide/HappyPathFirst:
26
55
  Description: "Corner cases should not be first context (happy path first)"
27
56
  Enabled: true
57
+ VersionAdded: "0.1.0"
58
+ VersionChanged: "0.2.1"
28
59
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
29
60
 
30
61
  RSpecGuide/ContextSetup:
31
62
  Description: "Context must have setup (let/before) to distinguish it from parent. Subject should be at describe level."
32
63
  Enabled: true
64
+ VersionAdded: "0.1.0"
65
+ VersionChanged: "0.2.0"
66
+ StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
67
+
68
+ FactoryBotGuide/DynamicAttributeEvaluation:
69
+ Description: "Wrap method calls in blocks for dynamic evaluation (Time.now, SecureRandom, etc.)"
70
+ Enabled: true
71
+ VersionAdded: "0.3.0"
33
72
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
34
73
 
35
74
  FactoryBotGuide/DynamicAttributesForTimeAndRandom:
36
- Description: "Wrap Time.now, SecureRandom and method calls in blocks for dynamic evaluation"
75
+ Description: "[DEPRECATED] Use FactoryBotGuide/DynamicAttributeEvaluation instead"
37
76
  Enabled: true
77
+ VersionAdded: "0.1.0"
78
+ VersionChanged: "0.3.0"
38
79
  StyleGuideUrl: "https://github.com/AlexeyMatskevich/rspec-guide"
@@ -0,0 +1,6 @@
1
+ # Cop obsoletion configuration for rubocop-rspec-guide
2
+ # This file defines renamed, removed, and deprecated cops
3
+
4
+ renamed:
5
+ RSpecGuide/CharacteristicsAndContexts: RSpecGuide/MinimumBehavioralCoverage
6
+ FactoryBotGuide/DynamicAttributesForTimeAndRandom: FactoryBotGuide/DynamicAttributeEvaluation
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module FactoryBotGuide
6
+ # Checks that method calls in FactoryBot attribute definitions
7
+ # are wrapped in blocks for dynamic evaluation.
8
+ #
9
+ # Without blocks, method calls are evaluated once at factory definition time,
10
+ # not at factory instantiation time. This causes all factory instances to share
11
+ # the same value, leading to subtle bugs and test pollution.
12
+ #
13
+ # This is particularly problematic for:
14
+ # - Time-related methods (Time.now, Date.today, 1.day.from_now, etc.)
15
+ # - Random values (SecureRandom.hex, SecureRandom.uuid, etc.)
16
+ # - Object constructors (Array.new, Hash.new, etc.)
17
+ # - Any method calls that should return different values per instance
18
+ #
19
+ # @safety
20
+ # This cop is safe to run automatically. It detects all method calls
21
+ # in factory attributes, not just Time/Random methods.
22
+ #
23
+ # @example Time-related methods
24
+ # # bad - all users get the same timestamp
25
+ # factory :user do
26
+ # created_at Time.now
27
+ # updated_at DateTime.now
28
+ # birth_date Date.today
29
+ # expires_at 1.day.from_now
30
+ # end
31
+ #
32
+ # # good - each user gets a fresh timestamp
33
+ # factory :user do
34
+ # created_at { Time.now }
35
+ # updated_at { DateTime.now }
36
+ # birth_date { Date.today }
37
+ # expires_at { 1.day.from_now }
38
+ # end
39
+ #
40
+ # @example Random values
41
+ # # bad - all users share the same token
42
+ # factory :user do
43
+ # token SecureRandom.hex
44
+ # uuid SecureRandom.uuid
45
+ # end
46
+ #
47
+ # # good - each user gets a unique token
48
+ # factory :user do
49
+ # token { SecureRandom.hex }
50
+ # uuid { SecureRandom.uuid }
51
+ # end
52
+ #
53
+ # @example Object constructors
54
+ # # bad - all users share the same array/hash instance!
55
+ # factory :user do
56
+ # tags Array.new
57
+ # metadata Hash.new
58
+ # end
59
+ #
60
+ # # This causes test pollution:
61
+ # user1 = create(:user)
62
+ # user1.tags << 'admin'
63
+ # user2 = create(:user)
64
+ # user2.tags # => ['admin'] - unexpected!
65
+ #
66
+ # # good - each user gets a new array/hash
67
+ # factory :user do
68
+ # tags { Array.new }
69
+ # metadata { Hash.new }
70
+ # end
71
+ #
72
+ # @example Static values (no block needed)
73
+ # # good - static values don't need blocks
74
+ # factory :user do
75
+ # name "John Doe"
76
+ # age 30
77
+ # active true
78
+ # end
79
+ #
80
+ # @example Complex expressions
81
+ # # bad - method chains evaluated once
82
+ # factory :user do
83
+ # full_name current_user.profile.display_name
84
+ # end
85
+ #
86
+ # # good - method chains evaluated per instance
87
+ # factory :user do
88
+ # full_name { current_user.profile.display_name }
89
+ # end
90
+ #
91
+ class DynamicAttributeEvaluation < Base
92
+ extend AutoCorrector
93
+
94
+ MSG = "Use block syntax for attribute `%<attribute>s` because `%<method>s` " \
95
+ "is evaluated once at factory definition time. " \
96
+ "Wrap in block: `%<attribute>s { %<value>s }`"
97
+
98
+ TIME_CLASSES = %w[Time Date DateTime].freeze
99
+ RANDOM_CLASSES = %w[SecureRandom].freeze
100
+
101
+ # @!method factory_block?(node)
102
+ def_node_matcher :factory_block?, <<~PATTERN
103
+ (block
104
+ (send {nil? (const {nil? cbase} :FactoryBot)} :factory ...)
105
+ ...)
106
+ PATTERN
107
+
108
+ # @!method attribute_assignment?(node)
109
+ def_node_matcher :attribute_assignment?, <<~PATTERN
110
+ (send nil? $_ $_value)
111
+ PATTERN
112
+
113
+ def on_block(node)
114
+ return unless factory_block?(node)
115
+
116
+ # Check all attribute assignments within the factory
117
+ node.each_descendant(:send) do |send_node|
118
+ check_attribute(send_node)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def check_attribute(node)
125
+ attribute_assignment?(node) do |attribute_name, value|
126
+ # Skip if value is already a block
127
+ next if value.block_type?
128
+
129
+ # Check if the value is a method call that needs dynamic evaluation
130
+ next unless requires_dynamic_evaluation?(value)
131
+
132
+ add_offense(
133
+ node,
134
+ message: format(
135
+ MSG,
136
+ attribute: attribute_name,
137
+ method: method_description(value),
138
+ value: value.source
139
+ )
140
+ ) do |corrector|
141
+ # Wrap the value in a block: value -> { value }
142
+ corrector.replace(value, "{ #{value.source} }")
143
+ end
144
+ end
145
+ end
146
+
147
+ def requires_dynamic_evaluation?(node)
148
+ # Only method calls need dynamic evaluation
149
+ # Static values (strings, symbols, numbers, etc.) are fine as-is
150
+ return false unless node.send_type?
151
+
152
+ # Time.now, Date.today, DateTime.now, etc.
153
+ return true if time_method?(node)
154
+
155
+ # SecureRandom.hex, SecureRandom.uuid, etc.
156
+ return true if random_method?(node)
157
+
158
+ # Any other method calls (e.g., 1.day.ago, Array.new, etc.)
159
+ # should be wrapped in blocks for dynamic evaluation
160
+ true
161
+ end
162
+
163
+ def time_method?(node)
164
+ return false unless node.receiver
165
+
166
+ receiver_name = if node.receiver.const_type?
167
+ node.receiver.const_name
168
+ end
169
+
170
+ TIME_CLASSES.include?(receiver_name)
171
+ end
172
+
173
+ def random_method?(node)
174
+ return false unless node.receiver
175
+
176
+ receiver_name = if node.receiver.const_type?
177
+ node.receiver.const_name
178
+ end
179
+
180
+ RANDOM_CLASSES.include?(receiver_name)
181
+ end
182
+
183
+ def method_description(node)
184
+ if node.receiver
185
+ "#{node.receiver.source}.#{node.method_name}"
186
+ else
187
+ node.method_name.to_s
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end