light-services 3.1.2 → 3.2.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: 1c9112f92c81696cc02f0d3440b848abca3a501f503b9bf95e645296d0a122dc
4
- data.tar.gz: 22c0672f290fc82f843d55f31060941dceedae0a991eb25697010ffd12b5fae9
3
+ metadata.gz: eaf377a62c2b05d82860527f64df11bb2239745d2e3b6ab1bc060cddc153effc
4
+ data.tar.gz: 86eb6a64e0744e6fe2f80b1b4fc06e9d38a740911f88c2b076da9e9a2aab082b
5
5
  SHA512:
6
- metadata.gz: 4b851d34f2c6a89a63a2d2f747db12b75f50741aabcbb3cbda4d8154a1ba65560894c2cabe4ac94f7f50f4ffc8fcdeb6280b83aa61a53888f76dd691c3cf1770
7
- data.tar.gz: 1e2ca16aaf58aee4d8adcda2d83ea881761a3f1c09543461edf5ad2516d91ee76b639eae20477c66c94619437b51a7e717932d5cc9750d18a0d6b2bd9e0e4036
6
+ metadata.gz: b11918fed0617837cd922a9c7c2181a855844d359bce30aa7f523a828e77e3605522cff64440cf200f90732cb17bbee901305a01920bde54a3cb4777b748a760
7
+ data.tar.gz: aa78896f8cf22b7e2cf17853700bbe347d0dd364409ee4033064d7c4c5fa57d9377620e5ab6b29473cf15c2894e29b189aa8634e7d71ffda9ad2ca6fa6fce792
@@ -0,0 +1,265 @@
1
+ ---
2
+ description: "Rules for managing Light Services - Ruby service objects with arguments, steps, and outputs"
3
+ globs: "**/services/**/*.rb"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Light Services Creation Rules
8
+
9
+ When creating or modifying services that inherit from `Light::Services::Base` or `ApplicationService`, follow these patterns.
10
+
11
+ ## Service Structure
12
+
13
+ Always structure services in this order:
14
+ 1. Configuration (`config`) if needed
15
+ 2. Arguments (`arg`)
16
+ 3. Steps (`step`)
17
+ 4. Outputs (`output`)
18
+ 5. Private methods implementing steps
19
+
20
+ ```ruby
21
+ class ModelName::ActionName < ApplicationService
22
+ # Arguments
23
+ arg :required_param, type: String
24
+ arg :optional_param, type: Integer, optional: true, default: 0
25
+
26
+ # Steps
27
+ step :validate
28
+ step :perform
29
+ step :cleanup, always: true
30
+
31
+ # Outputs
32
+ output :result, type: Hash
33
+
34
+ private
35
+
36
+ def validate
37
+ errors.add(:required_param, "can't be blank") if required_param.blank?
38
+ end
39
+
40
+ def perform
41
+ self.result = { success: true }
42
+ end
43
+
44
+ def cleanup
45
+ # Always runs
46
+ end
47
+ end
48
+ ```
49
+
50
+ ## Naming Conventions
51
+
52
+ - **Class name**: `ModelName::ActionVerb` (e.g., `User::Create`, `Order::Process`)
53
+ - **File path**: `app/services/model_name/action_verb.rb`
54
+ - **Step methods**: Use descriptive verbs (`validate`, `authorize`, `build_record`, `save`)
55
+
56
+ ## Arguments
57
+
58
+ ```ruby
59
+ # Required with type
60
+ arg :user_id, type: Integer
61
+
62
+ # Optional with default
63
+ arg :notify, type: [TrueClass, FalseClass], default: true
64
+
65
+ # Context argument (auto-passed to child services)
66
+ arg :current_user, type: User, optional: true, context: true
67
+
68
+ # Multiple allowed types
69
+ arg :id, type: [String, Integer]
70
+
71
+ # Proc default (evaluated at runtime)
72
+ arg :created_at, type: Time, default: -> { Time.current }
73
+ ```
74
+
75
+ ## Steps
76
+
77
+ ```ruby
78
+ # Basic step
79
+ step :process
80
+
81
+ # Conditional execution
82
+ step :send_email, if: :should_notify?
83
+ step :skip_audit, unless: :production?
84
+
85
+ # Always run (even after errors, unless stop! or stop_immediately! are called)
86
+ step :cleanup, always: true
87
+
88
+ # Insertion points (for inheritance)
89
+ step :log_action, before: :save
90
+ step :broadcast, after: :save
91
+ ```
92
+
93
+ ## Outputs
94
+
95
+ ```ruby
96
+ # Required output
97
+ output :user, type: User
98
+
99
+ # Optional output
100
+ output :metadata, type: Hash, optional: true
101
+
102
+ # With default
103
+ output :status, type: String, default: "completed"
104
+ ```
105
+
106
+ ## Error Handling
107
+
108
+ ```ruby
109
+ # Add error (stops execution of next steps by default)
110
+ errors.add(:email, "is invalid")
111
+
112
+ # Add to base
113
+ errors.add(:base, "Something went wrong")
114
+ fail!("Something went wrong") # Shorthand
115
+
116
+ # Copy from ActiveRecord model
117
+ errors.copy_from(user)
118
+
119
+ # Stop next steps without error (commits transaction)
120
+ stop!
121
+
122
+ # Stop with rollback
123
+ fail_immediately!("Critical error")
124
+ ```
125
+
126
+ ## Service Chaining
127
+
128
+ ```ruby
129
+ # Run in same context (shared arguments, errors propagate, rollback propagate)
130
+ ChildService.with(self).run(param: value)
131
+
132
+ # Independent service (separate transaction)
133
+ OtherService.run(param: value)
134
+ ```
135
+
136
+ ## Configuration
137
+
138
+ ```ruby
139
+ config use_transactions: true # Wrap in DB transaction (default)
140
+ config raise_on_error: false # Raise exception on error
141
+ config break_on_error: true # Stop on error (default)
142
+ config rollback_on_error: true # Rollback on error (default)
143
+ ```
144
+
145
+ ## Testing Pattern
146
+
147
+ ```ruby
148
+ RSpec.describe ModelName::ActionName do
149
+ describe ".run" do
150
+ subject(:service) { described_class.run(params) }
151
+ let(:params) { { required_param: "value" } }
152
+
153
+ context "when successful" do
154
+ it { is_expected.to be_successful }
155
+ it { expect(service.result).to eq(expected) }
156
+ end
157
+
158
+ context "when validation fails" do
159
+ let(:params) { { required_param: "" } }
160
+
161
+ it { is_expected.to be_failed }
162
+ it { is_expected.to have_error_on(:required_param) }
163
+ end
164
+ end
165
+ end
166
+ ```
167
+
168
+ ## Common Patterns
169
+
170
+ ### CRUD Create
171
+ ```ruby
172
+ class User::Create < CreateRecordService
173
+ private
174
+
175
+ def entity_class
176
+ User
177
+ end
178
+
179
+ def filtered_params
180
+ params.slice(:name, :email, :role)
181
+ end
182
+ end
183
+ ```
184
+
185
+ ### With Authorization
186
+ ```ruby
187
+ step :authorize
188
+ step :perform
189
+
190
+ def authorize
191
+ fail!("Not authorized") unless current_user&.admin?
192
+ end
193
+ ```
194
+
195
+ ### With Callbacks
196
+ ```ruby
197
+ before_service_run :log_start
198
+ after_service_run :log_finish
199
+ on_service_failure :notify_error
200
+ ```
201
+
202
+ ## Using dry-types
203
+
204
+ Light Services supports [dry-types](https://dry-rb.org/gems/dry-types) for advanced type validation and coercion.
205
+
206
+ ### Arguments with dry-types
207
+
208
+ ```ruby
209
+ class User::Create < ApplicationService
210
+ # Strict types - must match exactly
211
+ arg :name, type: Types::Strict::String
212
+
213
+ # Coercible types - automatically convert values
214
+ arg :age, type: Types::Coercible::Integer
215
+
216
+ # Constrained types - add validation rules
217
+ arg :email, type: Types::String.constrained(format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i)
218
+
219
+ # Enum types - restrict to specific values
220
+ arg :status, type: Types::String.enum("active", "inactive", "pending"), default: "pending"
221
+
222
+ # Array types with element validation
223
+ arg :tags, type: Types::Array.of(Types::String), optional: true
224
+
225
+ # Hash schemas with key transformation
226
+ arg :metadata, type: Types::Hash.schema(key: Types::String).with_key_transform(&:to_sym), optional: true
227
+ end
228
+ ```
229
+
230
+ ### Outputs with dry-types
231
+
232
+ ```ruby
233
+ class AI::Chat < ApplicationService
234
+ # Strict type validation
235
+ output :messages, type: Types::Strict::Array.of(Types::Hash)
236
+
237
+ # Coercible types
238
+ output :total_tokens, type: Types::Coercible::Integer
239
+
240
+ # Constrained types (must be >= 0)
241
+ output :cost, type: Types::Float.constrained(gteq: 0)
242
+ end
243
+ ```
244
+
245
+ ### Coercion Behavior
246
+
247
+ With coercible types, values are automatically converted:
248
+
249
+ ```ruby
250
+ # String "25" is automatically converted to integer 25
251
+ service = User::Create.run(name: "John", age: "25")
252
+ service.age # => 25 (Integer, not String)
253
+ ```
254
+
255
+ ### Common dry-types Patterns
256
+
257
+ | Type | Description |
258
+ |------|-------------|
259
+ | `Types::Strict::String` | Must be a String, no coercion |
260
+ | `Types::Coercible::Integer` | Coerces to Integer (e.g., "25" → 25) |
261
+ | `Types::String.optional` | String or nil |
262
+ | `Types::String.enum("a", "b")` | Must be one of the listed values |
263
+ | `Types::Array.of(Types::String)` | Array where all elements are Strings |
264
+ | `Types::Hash.schema(key: Type)` | Hash with typed schema |
265
+ | `Types::Float.constrained(gteq: 0)` | Float >= 0 |
@@ -0,0 +1,354 @@
1
+ ---
2
+ description: "Rules for writing RSpec tests for Light Services - testing arguments, steps, outputs, and behavior"
3
+ globs: "**/spec/services/*_spec.rb"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Light Services RSpec Testing Rules
8
+
9
+ When writing RSpec tests for services that inherit from `Light::Services::Base` or `ApplicationService`, follow these patterns.
10
+
11
+ ## Setup
12
+
13
+ Add to `spec/spec_helper.rb` or `spec/rails_helper.rb`:
14
+
15
+ ```ruby
16
+ require "light/services/rspec"
17
+ ```
18
+
19
+ ## Test Structure
20
+
21
+ Always structure test files in this order:
22
+ 1. Describe block with service class
23
+ 2. DSL definition tests (arguments, outputs, steps)
24
+ 3. Behavior tests (`.run` scenarios)
25
+ 4. Edge cases and error handling
26
+
27
+ ```ruby
28
+ # spec/services/model_name/action_name_spec.rb
29
+ RSpec.describe ModelName::ActionName do
30
+ # DSL Definition Tests
31
+ describe "arguments" do
32
+ it { expect(described_class).to define_argument(:required_param).with_type(String) }
33
+ it { expect(described_class).to define_argument(:optional_param).with_type(Integer).optional.with_default(0) }
34
+ end
35
+
36
+ describe "outputs" do
37
+ it { expect(described_class).to define_output(:result).with_type(Hash) }
38
+ end
39
+
40
+ describe "steps" do
41
+ it { expect(described_class).to define_steps_in_order(:validate, :perform, :cleanup) }
42
+ it { expect(described_class).to define_step(:cleanup).with_always(true) }
43
+ end
44
+
45
+ # Behavior Tests
46
+ describe ".run" do
47
+ subject(:service) { described_class.run(params) }
48
+ let(:params) { { required_param: "value" } }
49
+
50
+ context "when successful" do
51
+ it { is_expected.to be_successful }
52
+ it { expect(service.result).to eq(expected_result) }
53
+ end
54
+
55
+ context "when validation fails" do
56
+ let(:params) { { required_param: "" } }
57
+ it { is_expected.to be_failed }
58
+ it { is_expected.to have_error_on(:required_param) }
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ ## Naming Conventions
65
+
66
+ - **File path**: `spec/services/model_name/action_name_spec.rb`
67
+ - **Describe block**: Use the service class name directly
68
+ - **Context blocks**: Start with "when" or "with" to describe conditions
69
+ - **It blocks**: Be specific about what is being tested
70
+
71
+ ## DSL Matchers
72
+
73
+ ### Arguments
74
+
75
+ ```ruby
76
+ # Basic argument
77
+ it { expect(described_class).to define_argument(:id) }
78
+
79
+ # With type (single or multiple)
80
+ it { expect(described_class).to define_argument(:id).with_type([String, Integer]) }
81
+
82
+ # Optional with default
83
+ it { expect(described_class).to define_argument(:status).optional.with_default("pending") }
84
+
85
+ # Context argument
86
+ it { expect(described_class).to define_argument(:current_user).with_context }
87
+ ```
88
+
89
+ ### Outputs
90
+
91
+ ```ruby
92
+ # Basic output
93
+ it { expect(described_class).to define_output(:user) }
94
+
95
+ # With type
96
+ it { expect(described_class).to define_output(:user).with_type(User) }
97
+
98
+ # Optional with default
99
+ it { expect(described_class).to define_output(:count).optional.with_default(0) }
100
+ ```
101
+
102
+ ### Steps
103
+
104
+ ```ruby
105
+ # Basic step
106
+ it { expect(described_class).to define_step(:validate) }
107
+
108
+ # With always flag
109
+ it { expect(described_class).to define_step(:cleanup).with_always(true) }
110
+
111
+ # Conditional steps
112
+ it { expect(described_class).to define_step(:notify).with_if(:should_notify?) }
113
+ it { expect(described_class).to define_step(:skip_audit).with_unless(:production?) }
114
+
115
+ # Multiple steps in order
116
+ it { expect(described_class).to define_steps_in_order(:validate, :process, :save) }
117
+ ```
118
+
119
+ ## Behavior Testing
120
+
121
+ ### Success Cases
122
+
123
+ ```ruby
124
+ describe ".run" do
125
+ subject(:service) { described_class.run(params) }
126
+ let(:params) { { name: "John", email: "john@example.com" } }
127
+
128
+ it { is_expected.to be_successful }
129
+ it { expect(service.user).to be_persisted }
130
+ it { expect(service.user.name).to eq("John") }
131
+ end
132
+ ```
133
+
134
+ ### Failure Cases
135
+
136
+ ```ruby
137
+ context "when validation fails" do
138
+ let(:params) { { name: "", email: "" } }
139
+
140
+ it { is_expected.to be_failed }
141
+ it { is_expected.to have_error_on(:name) }
142
+ it { is_expected.to have_error_on(:email).with_message("can't be blank") }
143
+ it { is_expected.to have_errors_on(:name, :email) }
144
+ end
145
+ ```
146
+
147
+ ### Warnings
148
+
149
+ ```ruby
150
+ it { expect(service.warnings?).to be true }
151
+ it { is_expected.to have_warning_on(:format).with_message("format is deprecated") }
152
+ ```
153
+
154
+ ### run! vs run
155
+
156
+ ```ruby
157
+ # .run returns failed service
158
+ it { expect(described_class.run(amount: -100)).to be_failed }
159
+
160
+ # .run! raises exception
161
+ it { expect { described_class.run!(amount: -100) }.to raise_error(Light::Services::Error, /must be positive/) }
162
+ ```
163
+
164
+ ### Config Overrides
165
+
166
+ ```ruby
167
+ # With raise_on_error
168
+ expect { described_class.run({ invalid: true }, { raise_on_error: true }) }
169
+ .to raise_error(Light::Services::Error)
170
+
171
+ # With use_transactions: false
172
+ service = described_class.with(use_transactions: false).run(params)
173
+ ```
174
+
175
+ ## Database Testing
176
+
177
+ ```ruby
178
+ # Record creation
179
+ expect { described_class.run(params) }.to change(User, :count).by(1)
180
+ expect(service.user).to be_persisted
181
+
182
+ # Transaction rollback on failure
183
+ allow_any_instance_of(ChildService).to receive(:perform) do |svc|
184
+ svc.errors.add(:base, "Simulated failure")
185
+ end
186
+ expect { described_class.run(params) }.not_to change(Order, :count)
187
+ ```
188
+
189
+ ## Service Chaining
190
+
191
+ ```ruby
192
+ # Verify context sharing
193
+ expect(ChildService).to receive(:with).and_call_original
194
+ described_class.run(current_user: current_user, data: data)
195
+
196
+ # Error propagation from child
197
+ allow_any_instance_of(ChildService).to receive(:validate) { |svc| svc.fail!("Child error") }
198
+ expect(described_class.run(current_user: current_user, data: data))
199
+ .to have_error_on(:base).with_message("Child error")
200
+ ```
201
+
202
+ ## Conditional Steps
203
+
204
+ ```ruby
205
+ # When condition is true
206
+ expect { described_class.run(send_notification: true, email: "x@example.com") }
207
+ .to have_enqueued_mail(UserMailer, :notification)
208
+
209
+ # When condition is false
210
+ expect { described_class.run(send_notification: false, email: "x@example.com") }
211
+ .not_to have_enqueued_mail(UserMailer, :notification)
212
+ ```
213
+
214
+ ## Early Exit (stop!)
215
+
216
+ ```ruby
217
+ existing_user = create(:user, email: "exists@example.com")
218
+ service = described_class.run(email: existing_user.email)
219
+
220
+ expect { service }.not_to change(User, :count)
221
+ expect(service.user).to eq(existing_user)
222
+ expect(service).to be_successful
223
+ expect(service.stopped?).to be true
224
+ ```
225
+
226
+ ## Argument Validation
227
+
228
+ ```ruby
229
+ # Required argument nil
230
+ expect { described_class.run(name: nil) }.to raise_error(Light::Services::ArgTypeError)
231
+
232
+ # Wrong type
233
+ expect { described_class.run(name: 123) }.to raise_error(Light::Services::ArgTypeError, /must be a String/)
234
+
235
+ # Optional accepts nil
236
+ expect(described_class.run(name: "John", nickname: nil)).to be_successful
237
+
238
+ # Default values
239
+ expect(described_class.run(name: "John").status).to eq("pending")
240
+ ```
241
+
242
+ ## External Services
243
+
244
+ ```ruby
245
+ let(:stripe_client) { instance_double(Stripe::PaymentIntent, id: "pi_123") }
246
+
247
+ before { allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_client) }
248
+
249
+ it "processes payment" do
250
+ service = described_class.run(amount: 1000, card_token: "tok_visa")
251
+ expect(service).to be_successful
252
+ expect(service.payment_intent_id).to eq("pi_123")
253
+ end
254
+
255
+ context "when external fails" do
256
+ before { allow(Stripe::PaymentIntent).to receive(:create).and_raise(Stripe::CardError.new("Card declined", nil, nil)) }
257
+
258
+ it { expect(service).to be_failed }
259
+ it { expect(service).to have_error_on(:payment).with_message("Card declined") }
260
+ end
261
+ ```
262
+
263
+ ## Optional Tracking
264
+
265
+ ### Step Execution
266
+
267
+ ```ruby
268
+ # app/services/application_service.rb
269
+ class ApplicationService < Light::Services::Base
270
+ output :executed_steps, type: Array, default: -> { [] }
271
+ after_step_run { |service, step| service.executed_steps << step }
272
+ end
273
+ ```
274
+
275
+ ### Callback Tracking
276
+
277
+ ```ruby
278
+ class ApplicationService < Light::Services::Base
279
+ output :callback_log, type: Array, default: -> { [] }
280
+ before_service_run { |s| s.callback_log << :before_service_run }
281
+ after_service_run { |s| s.callback_log << :after_service_run }
282
+ on_service_success { |s| s.callback_log << :on_service_success }
283
+ on_service_failure { |s| s.callback_log << :on_service_failure }
284
+ end
285
+ ```
286
+
287
+ ## Shared Examples
288
+
289
+ ### Create Service
290
+
291
+ ```ruby
292
+ RSpec.shared_examples "a create service" do |model_class|
293
+ let(:valid_attributes) { attributes_for(model_class.name.underscore.to_sym) }
294
+ let(:current_user) { create(:user) }
295
+
296
+ it { expect { described_class.run(current_user: current_user, attributes: valid_attributes) }.to change(model_class, :count).by(1) }
297
+ it { expect(described_class.run(current_user: current_user, attributes: valid_attributes).record).to be_persisted }
298
+ it { expect(described_class.run(current_user: current_user, attributes: valid_attributes)).to be_successful }
299
+ end
300
+ ```
301
+
302
+ ### Authorized Service
303
+
304
+ ```ruby
305
+ RSpec.shared_examples "an authorized service" do
306
+ context "without current_user" do
307
+ let(:current_user) { nil }
308
+ it { is_expected.to be_failed }
309
+ it { is_expected.to have_error_on(:authorization) }
310
+ end
311
+
312
+ context "with unauthorized user" do
313
+ let(:current_user) { create(:user, role: :guest) }
314
+ it { is_expected.to be_failed }
315
+ it { is_expected.to have_error_on(:authorization) }
316
+ end
317
+ end
318
+ ```
319
+
320
+ ## Test Helpers
321
+
322
+ ```ruby
323
+ module ServiceHelpers
324
+ def expect_service_success(service)
325
+ expect(service).to be_successful, -> { "Expected success but got errors: #{service.errors.to_h}" }
326
+ end
327
+
328
+ def expect_service_failure(service, key = nil)
329
+ expect(service).to be_failed
330
+ expect(service.errors[key]).to be_present if key
331
+ end
332
+ end
333
+
334
+ RSpec.configure { |config| config.include ServiceHelpers, type: :service }
335
+ ```
336
+
337
+ ## Common Matchers Reference
338
+
339
+ | Matcher | Description |
340
+ |---------|-------------|
341
+ | `be_successful` | Service completed without errors |
342
+ | `be_failed` | Service has errors |
343
+ | `have_error_on(:key)` | Has error on specific key |
344
+ | `have_error_on(:key).with_message(msg)` | Error with specific message |
345
+ | `have_errors_on(:key1, :key2)` | Has errors on multiple keys |
346
+ | `have_warning_on(:key)` | Has warning on specific key |
347
+ | `define_argument(:name)` | Service defines argument |
348
+ | `define_output(:name)` | Service defines output |
349
+ | `define_step(:name)` | Service defines step |
350
+ | `define_steps(:a, :b, :c)` | Service defines all steps (any order) |
351
+ | `define_steps_in_order(:a, :b, :c)` | Service defines steps in order |
352
+ | `execute_step(:name)` | Step was executed (tracking required) |
353
+ | `skip_step(:name)` | Step was skipped (tracking required) |
354
+ | `trigger_callback(:name)` | Callback was triggered (tracking required) |
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.2.0 (2025-12-14)
4
+
5
+ ### Added
6
+
7
+ - Add Cursor rules
8
+ - Add `successful?` as an alias for `success?`
9
+ - Add RuboCop cop `PreferFailMethod` to detect `errors.add(:base, "message")` and suggest using `fail!("message")` instead
10
+
11
+ ### Breaking changes
12
+
13
+ - Service runs steps with `always: true` after `fail_immediately!` was called
14
+
3
15
  ## 3.1.2 (2025-12-13)
4
16
 
5
17
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- light-services (3.1.1)
4
+ light-services (3.2.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -33,7 +33,7 @@ GEM
33
33
  bigdecimal (3.3.1)
34
34
  codecov (0.6.0)
35
35
  simplecov (>= 0.15, < 0.22)
36
- concurrent-ruby (1.3.5)
36
+ concurrent-ruby (1.3.6)
37
37
  connection_pool (2.5.5)
38
38
  database_cleaner-active_record (2.2.2)
39
39
  activerecord (>= 5.a)
data/docs/generators.md CHANGED
@@ -216,7 +216,7 @@ RSpec.describe User::Create do
216
216
  describe ".run" do
217
217
  it "creates a user" do
218
218
  service = described_class.run(...)
219
- expect(service).to be_success
219
+ expect(service).to be_successful
220
220
  end
221
221
  end
222
222
  end
@@ -252,7 +252,7 @@ RSpec.describe Post::Update do
252
252
  attributes: { title: "New Title" }
253
253
  )
254
254
 
255
- expect(service).to be_success
255
+ expect(service).to be_successful
256
256
  expect(post.reload.title).to eq("New Title")
257
257
  end
258
258
  end
data/docs/rubocop.md CHANGED
@@ -232,6 +232,47 @@ LightServices/DeprecatedMethods:
232
232
  ServicePattern: 'Service$' # default: matches classes ending with "Service"
233
233
  ```
234
234
 
235
+ ### LightServices/PreferFailMethod
236
+
237
+ Detects `errors.add(:base, "message")` calls and suggests using the `fail!("message")` helper instead. Includes autocorrection.
238
+
239
+ ```ruby
240
+ # bad
241
+ class MyService < ApplicationService
242
+ step :process
243
+
244
+ private
245
+
246
+ def process
247
+ errors.add(:base, "user is required")
248
+ errors.add(:base, "invalid input", rollback: false)
249
+ end
250
+ end
251
+
252
+ # good
253
+ class MyService < ApplicationService
254
+ step :process
255
+
256
+ private
257
+
258
+ def process
259
+ fail!("user is required")
260
+ fail!("invalid input", rollback: false)
261
+ end
262
+ end
263
+ ```
264
+
265
+ The cop only detects `errors.add(:base, ...)` calls. It does not flag `errors.add(:field_name, ...)` calls for specific fields, as those should not use `fail!`.
266
+
267
+ **Configuration:** Customize the base service classes to check:
268
+
269
+ ```yaml
270
+ LightServices/PreferFailMethod:
271
+ BaseServiceClasses:
272
+ - ApplicationService
273
+ - BaseCreator
274
+ ```
275
+
235
276
  ## Configuration
236
277
 
237
278
  Full configuration example:
@@ -267,6 +308,11 @@ LightServices/NoDirectInstantiation:
267
308
  LightServices/DeprecatedMethods:
268
309
  Enabled: true
269
310
  ServicePattern: 'Service$'
311
+
312
+ LightServices/PreferFailMethod:
313
+ Enabled: true
314
+ BaseServiceClasses:
315
+ - ApplicationService
270
316
  ```
271
317
 
272
318
  To disable a cop for specific files:
data/docs/steps.md CHANGED
@@ -325,8 +325,8 @@ class Payment::Process < ApplicationService
325
325
  end
326
326
  ```
327
327
 
328
- {% hint style="warning" %}
329
- `fail_immediately!` raises an internal exception to halt execution. Steps marked with `always: true` will NOT run when `fail_immediately!` is called.
328
+ {% hint style="info" %}
329
+ `fail_immediately!` raises an internal exception to halt execution. Steps marked with `always: true` will still run when `fail_immediately!` is called, allowing for cleanup operations.
330
330
  {% endhint %}
331
331
 
332
332
  {% hint style="danger" %}
data/docs/testing.md CHANGED
@@ -30,7 +30,7 @@ RSpec.describe GreetService do
30
30
  it "returns a greeting message" do
31
31
  service = described_class.run(name: "John")
32
32
 
33
- expect(service).to be_success
33
+ expect(service).to be_successful
34
34
  expect(service.message).to eq("Hello, John!")
35
35
  end
36
36
  end
@@ -48,7 +48,7 @@ RSpec.describe User::Create do
48
48
  it "creates a user" do
49
49
  service = described_class.run(attributes: attributes)
50
50
 
51
- expect(service).to be_success
51
+ expect(service).to be_successful
52
52
  expect(service.user).to be_persisted
53
53
  expect(service.user.email).to eq("test@example.com")
54
54
  end
@@ -86,7 +86,7 @@ RSpec.describe Comment::Create do
86
86
  text: "Great post!"
87
87
  )
88
88
 
89
- expect(service).to be_success
89
+ expect(service).to be_successful
90
90
  expect(service.comment.user).to eq(current_user)
91
91
  end
92
92
  end
@@ -122,7 +122,7 @@ RSpec.describe Order::Create do
122
122
  items: [{ product_id: product.id, quantity: 2 }]
123
123
  )
124
124
 
125
- expect(service).to be_success
125
+ expect(service).to be_successful
126
126
  expect(service.order.order_items.count).to eq(1)
127
127
  expect(service.order.total).to eq(200)
128
128
  end
@@ -252,7 +252,7 @@ RSpec.describe DataImport do
252
252
  it "completes with warnings for skipped records" do
253
253
  service = described_class.run(data: mixed_valid_invalid_data)
254
254
 
255
- expect(service).to be_success # Warnings don't fail the service
255
+ expect(service).to be_successful # Warnings don't fail the service
256
256
  expect(service.warnings?).to be true
257
257
  expect(service.warnings[:skipped]).to include("Row 3: invalid format")
258
258
  end
@@ -273,7 +273,7 @@ RSpec.describe Payment::Charge do
273
273
  it "processes payment successfully" do
274
274
  service = described_class.run(amount: 1000, card_token: "tok_visa")
275
275
 
276
- expect(service).to be_success
276
+ expect(service).to be_successful
277
277
  expect(service.payment_intent_id).to eq("pi_123")
278
278
  end
279
279
 
@@ -312,7 +312,7 @@ RSpec.describe MyService do
312
312
 
313
313
  it "accepts optional arguments as nil" do
314
314
  service = described_class.run(name: "John", nickname: nil)
315
- expect(service).to be_success
315
+ expect(service).to be_successful
316
316
  end
317
317
  end
318
318
  end
@@ -353,7 +353,7 @@ Create a helper module for common service testing patterns:
353
353
  # spec/support/service_helpers.rb
354
354
  module ServiceHelpers
355
355
  def expect_service_success(service)
356
- expect(service).to be_success, -> { "Expected success but got errors: #{service.errors.to_h}" }
356
+ expect(service).to be_successful, -> { "Expected success but got errors: #{service.errors.to_h}" }
357
357
  end
358
358
 
359
359
  def expect_service_failure(service, key = nil)
@@ -34,7 +34,7 @@ RSpec.describe <%= class_name %>, type: :service do
34
34
  let(:args) { {} }
35
35
 
36
36
  it "succeeds" do
37
- expect(service).to be_success
37
+ expect(service).to be_successful
38
38
  end
39
39
  end
40
40
  end
@@ -89,6 +89,7 @@ module Light
89
89
  def success?
90
90
  !errors?
91
91
  end
92
+ alias successful? success?
92
93
 
93
94
  # Check if the service completed with errors.
94
95
  #
@@ -145,12 +146,12 @@ module Light
145
146
  end
146
147
 
147
148
  # Add an error and stop execution immediately, causing transaction rollback.
149
+ # Steps marked with `always: true` will still run after this method is called.
148
150
  #
149
151
  # @param message [String] the error message
150
152
  # @raise [FailExecution] always raises to halt execution and rollback
151
153
  # @return [void]
152
154
  def fail_immediately!(message)
153
- @stopped = true
154
155
  errors.add(:base, message, rollback: false)
155
156
  raise Light::Services::FailExecution
156
157
  end
@@ -46,7 +46,6 @@ module Light
46
46
  end
47
47
  rescue Light::Services::FailExecution
48
48
  # FailExecution bubbles out of transaction (causing rollback) but is caught here
49
- # @stopped is already set by fail_immediately!
50
49
  nil
51
50
  end
52
51
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
6
+ # Detects `errors.add(:base, "message")` and suggests using `fail!("message")` instead.
7
+ #
8
+ # This cop checks calls inside service classes that inherit from
9
+ # Light::Services::Base or any configured base service classes.
10
+ #
11
+ # @safety
12
+ # This cop's autocorrection is safe as `fail!` is a wrapper method
13
+ # that calls `errors.add(:base, message)` internally.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # class User::Create < ApplicationService
18
+ # step :process
19
+ #
20
+ # private
21
+ #
22
+ # def process
23
+ # errors.add(:base, "user is required")
24
+ # end
25
+ # end
26
+ #
27
+ # # good
28
+ # class User::Create < ApplicationService
29
+ # step :process
30
+ #
31
+ # private
32
+ #
33
+ # def process
34
+ # fail!("user is required")
35
+ # end
36
+ # end
37
+ #
38
+ class PreferFailMethod < Base
39
+ extend AutoCorrector
40
+
41
+ MSG = "Use `fail!(...)` instead of `errors.add(:base, ...)`."
42
+
43
+ def_node_matcher :errors_add_base?, <<~PATTERN
44
+ (send
45
+ (send nil? :errors) :add
46
+ (sym :base)
47
+ ...)
48
+ PATTERN
49
+
50
+ DEFAULT_BASE_CLASSES = ["ApplicationService"].freeze
51
+
52
+ def on_class(node)
53
+ @in_service_class = service_class?(node)
54
+ end
55
+
56
+ def after_class(_node)
57
+ @in_service_class = false
58
+ end
59
+
60
+ def on_send(node)
61
+ return unless @in_service_class
62
+ return unless node.method_name == :add
63
+ return unless node.receiver&.method_name == :errors
64
+
65
+ return unless errors_add_base?(node)
66
+
67
+ # Only flag if there's a message argument after :base
68
+ # errors.add(:base) without a message is invalid Light Services syntax
69
+ message_args = node.arguments[1..]
70
+ return if message_args.empty?
71
+
72
+ add_offense(node, message: MSG) do |corrector|
73
+ autocorrect(corrector, node, message_args)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def autocorrect(corrector, node, message_args)
80
+ # Get the source code for all arguments after the :base symbol
81
+ args_source = message_args.map(&:source).join(", ")
82
+ replacement = "fail!(#{args_source})"
83
+
84
+ corrector.replace(node, replacement)
85
+ end
86
+
87
+ def service_class?(node)
88
+ return false unless node.parent_class
89
+
90
+ parent_class_name = extract_class_name(node.parent_class)
91
+ return false unless parent_class_name
92
+
93
+ # Check for direct Light::Services::Base inheritance
94
+ return true if parent_class_name == "Light::Services::Base"
95
+
96
+ # Check against configured base service classes
97
+ base_classes = cop_config.fetch("BaseServiceClasses", DEFAULT_BASE_CLASSES)
98
+ base_classes.include?(parent_class_name)
99
+ end
100
+
101
+ def extract_class_name(node)
102
+ case node.type
103
+ when :const
104
+ node.const_name
105
+ when :send
106
+ # For namespaced constants like Light::Services::Base
107
+ node.source
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -9,4 +9,5 @@ require_relative "rubocop/cop/light_services/dsl_order"
9
9
  require_relative "rubocop/cop/light_services/missing_private_keyword"
10
10
  require_relative "rubocop/cop/light_services/no_direct_instantiation"
11
11
  require_relative "rubocop/cop/light_services/output_type_required"
12
+ require_relative "rubocop/cop/light_services/prefer_fail_method"
12
13
  require_relative "rubocop/cop/light_services/step_method_exists"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Light
4
4
  module Services
5
- VERSION = "3.1.2"
5
+ VERSION = "3.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: light-services
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.2
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kodkod
@@ -17,6 +17,8 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - ".cursor/rules/services-rspec/RULE.md"
21
+ - ".cursor/rules/services/RULE.md"
20
22
  - ".github/config/rubocop_linter_action.yml"
21
23
  - ".github/dependabot.yml"
22
24
  - ".github/workflows/ci.yml"
@@ -96,6 +98,7 @@ files:
96
98
  - lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb
97
99
  - lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb
98
100
  - lib/light/services/rubocop/cop/light_services/output_type_required.rb
101
+ - lib/light/services/rubocop/cop/light_services/prefer_fail_method.rb
99
102
  - lib/light/services/rubocop/cop/light_services/step_method_exists.rb
100
103
  - lib/light/services/settings/field.rb
101
104
  - lib/light/services/settings/step.rb