backspin 0.4.1 → 0.4.2

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gem_release.yml +13 -0
  3. data/CHANGELOG.md +5 -7
  4. data/CLAUDE.md +5 -1
  5. data/Gemfile +3 -1
  6. data/Gemfile.lock +3 -1
  7. data/MATCH_ON_USAGE.md +110 -0
  8. data/Rakefile +5 -1
  9. data/backspin.gemspec +6 -3
  10. data/examples/match_on_example.rb +116 -0
  11. data/fixtures/backspin/all_and_fields.yml +15 -0
  12. data/fixtures/backspin/all_bypass_equality.yml +14 -0
  13. data/fixtures/backspin/all_checks_equality.yml +17 -0
  14. data/fixtures/backspin/all_for_logging.yml +13 -0
  15. data/fixtures/backspin/all_matcher_basic.yml +14 -0
  16. data/fixtures/backspin/all_matcher_custom.yml +17 -0
  17. data/fixtures/backspin/all_matcher_demo.yml +14 -0
  18. data/fixtures/backspin/all_matcher_test.yml +14 -0
  19. data/fixtures/backspin/all_no_short_circuit.yml +14 -0
  20. data/fixtures/backspin/all_pass_field_fail.yml +14 -0
  21. data/fixtures/backspin/all_short_circuit.yml +14 -0
  22. data/fixtures/backspin/all_skips_equality.yml +17 -0
  23. data/fixtures/backspin/all_with_equality.yml +17 -0
  24. data/fixtures/backspin/all_with_fields.yml +17 -0
  25. data/fixtures/backspin/combined_fail_demo.yml +14 -0
  26. data/fixtures/backspin/combined_matcher_demo.yml +14 -0
  27. data/fixtures/backspin/field_matcher_demo.yml +17 -0
  28. data/fixtures/backspin/field_matcher_values.yml +14 -0
  29. data/fixtures/backspin/key_confusion_test.yml +14 -0
  30. data/fixtures/backspin/match_on_any_fail.yml +21 -0
  31. data/fixtures/backspin/match_on_bad_format.yml +14 -0
  32. data/fixtures/backspin/match_on_fail.yml +15 -0
  33. data/fixtures/backspin/match_on_invalid.yml +14 -0
  34. data/fixtures/backspin/match_on_multiple.yml +28 -0
  35. data/fixtures/backspin/match_on_nil.yml +14 -0
  36. data/fixtures/backspin/match_on_other_fields.yml +23 -0
  37. data/fixtures/backspin/match_on_run_bang.yml +16 -0
  38. data/fixtures/backspin/match_on_run_bang_fail.yml +15 -0
  39. data/fixtures/backspin/match_on_single.yml +17 -0
  40. data/fixtures/backspin/string_symbol_test.yml +14 -0
  41. data/lib/backspin/command.rb +1 -5
  42. data/lib/backspin/command_diff.rb +98 -16
  43. data/lib/backspin/command_result.rb +2 -4
  44. data/lib/backspin/record.rb +31 -10
  45. data/lib/backspin/record_result.rb +20 -14
  46. data/lib/backspin/recorder.rb +100 -55
  47. data/lib/backspin/version.rb +3 -1
  48. data/lib/backspin.rb +34 -173
  49. data/release.rake +97 -0
  50. data/script/lint +6 -0
  51. metadata +54 -5
@@ -1,25 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "open3"
2
4
  require "ostruct"
3
5
  require "rspec/mocks"
6
+ require "backspin/command_result"
7
+ require "backspin/command_diff"
4
8
 
5
9
  module Backspin
6
10
  # Handles stubbing and recording of command executions
7
11
  class Recorder
8
12
  include RSpec::Mocks::ExampleMethods
9
- SUPPORTED_COMMAND_TYPES = [:capture3, :system]
13
+ SUPPORTED_COMMAND_TYPES = %i[capture3 system].freeze
10
14
 
11
- attr_reader :commands, :verification_data, :mode, :record
15
+ attr_reader :commands, :mode, :record, :options
12
16
 
13
- def initialize(mode: :record, record: nil)
17
+ def initialize(mode: :record, record: nil, options: {})
14
18
  @mode = mode
15
19
  @record = record
20
+ @options = options
16
21
  @commands = []
17
- @verification_data = {}
22
+ @playback_index = 0
23
+ @command_diffs = []
18
24
  end
19
25
 
20
- def record_calls(*command_types)
26
+ def setup_recording_stubs(*command_types)
21
27
  command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
22
-
23
28
  command_types.each do |command_type|
24
29
  record_call(command_type)
25
30
  end
@@ -32,67 +37,110 @@ module Backspin
32
37
  when :capture3
33
38
  setup_capture3_call_stub
34
39
  else
35
- raise ArgumentError, "Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
40
+ raise ArgumentError,
41
+ "Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
36
42
  end
37
43
  end
38
44
 
39
- # Setup stubs for playback mode - just return recorded values
40
- def setup_playback_stub(command)
41
- if command.method_class == Open3::Capture3
42
- actual_status = OpenStruct.new(exitstatus: command.status)
43
- allow(Open3).to receive(:capture3).and_return([command.stdout, command.stderr, actual_status])
44
- elsif command.method_class == ::Kernel::System
45
- # For system, return true if exit status was 0
46
- allow_any_instance_of(Object).to receive(:system).and_return(command.status == 0)
47
- end
45
+ # Records registered commands, adds them to the record, saves the record, and returns the overall RecordResult
46
+ def perform_recording
47
+ result = yield
48
+ record.save(filter: options[:filter])
49
+ RecordResult.new(output: result, mode: :record, record: record)
48
50
  end
49
51
 
50
- # Setup stubs for verification - capture actual output
51
- def setup_verification_stub(command)
52
- @verification_data = {}
53
-
54
- if command.method_class == Open3::Capture3
55
- allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
56
- stdout, stderr, status = original_method.call(*args)
57
- @verification_data["stdout"] = stdout
58
- @verification_data["stderr"] = stderr
59
- @verification_data["status"] = status.exitstatus
60
- [stdout, stderr, status]
61
- end
62
- elsif command.method_class == ::Kernel::System
63
- allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
64
- # Execute the real system call
65
- result = original_method.call(receiver, *args)
52
+ # Performs verification by executing commands and comparing with recorded values
53
+ def perform_verification
54
+ raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
55
+ raise RecordNotFoundError, "No commands found in record" if record.empty?
66
56
 
67
- # For system calls, we only track the exit status
68
- @verification_data["stdout"] = ""
69
- @verification_data["stderr"] = ""
70
- @verification_data["status"] = result ? 0 : 1
57
+ # Initialize tracking variables
58
+ @command_diffs = []
59
+ @command_index = 0
71
60
 
72
- result
61
+ # Setup verification stubs for capture3
62
+ allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
63
+ recorded_command = record.commands[@command_index]
64
+
65
+ if recorded_command.nil?
66
+ raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
73
67
  end
68
+
69
+ stdout, stderr, status = original_method.call(*args)
70
+
71
+ actual_command = Command.new(
72
+ method_class: Open3::Capture3,
73
+ args: args,
74
+ stdout: stdout,
75
+ stderr: stderr,
76
+ status: status.exitstatus
77
+ )
78
+
79
+ @command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
80
+ @command_index += 1
81
+ [stdout, stderr, status]
74
82
  end
83
+
84
+ # Setup verification stubs for system
85
+ allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
86
+ recorded_command = record.commands[@command_index]
87
+
88
+ result = original_method.call(receiver, *args)
89
+
90
+ actual_command = Command.new(
91
+ method_class: ::Kernel::System,
92
+ args: args,
93
+ stdout: "",
94
+ stderr: "",
95
+ status: result ? 0 : 1
96
+ )
97
+
98
+ # Create CommandDiff to track the comparison
99
+ @command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
100
+
101
+ @command_index += 1
102
+ result
103
+ end
104
+
105
+ output = yield
106
+
107
+ all_verified = @command_diffs.all?(&:verified?)
108
+
109
+ RecordResult.new(
110
+ output: output,
111
+ mode: :verify,
112
+ verified: all_verified,
113
+ record: record,
114
+ command_diffs: @command_diffs
115
+ )
75
116
  end
76
117
 
77
- # Setup stubs for replay mode - returns recorded values for multiple commands
78
- def setup_replay_stubs
79
- raise ArgumentError, "Record required for replay mode" unless @record
118
+ # Performs playback by returning recorded values without executing actual commands
119
+ def perform_playback
120
+ raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
121
+ raise RecordNotFoundError, "No commands found in record" if record.empty?
80
122
 
123
+ # Setup replay stubs
81
124
  setup_capture3_replay_stub
82
125
  setup_system_replay_stub
126
+
127
+ # Execute block (all commands will be stubbed with recorded values)
128
+ output = yield
129
+
130
+ RecordResult.new(
131
+ output: output,
132
+ mode: :playback,
133
+ verified: true, # Always true for playback
134
+ record: record
135
+ )
83
136
  end
84
137
 
85
138
  private
86
139
 
87
140
  def setup_capture3_replay_stub
88
- allow(Open3).to receive(:capture3) do |*args|
141
+ allow(Open3).to receive(:capture3) do |*_args|
89
142
  command = @record.next_command
90
143
 
91
- # Make sure this is a capture3 command
92
- unless command.method_class == Open3::Capture3
93
- raise RecordNotFoundError, "Expected Open3::Capture3 command but got #{command.method_class.name}"
94
- end
95
-
96
144
  recorded_stdout = command.stdout
97
145
  recorded_stderr = command.stderr
98
146
  recorded_status = OpenStruct.new(exitstatus: command.status)
@@ -104,14 +152,10 @@ module Backspin
104
152
  end
105
153
 
106
154
  def setup_system_replay_stub
107
- allow_any_instance_of(Object).to receive(:system) do |receiver, *args|
155
+ allow_any_instance_of(Object).to receive(:system) do |_receiver, *_args|
108
156
  command = @record.next_command
109
157
 
110
- unless command.method_class == ::Kernel::System
111
- raise RecordNotFoundError, "Expected Kernel::System command but got #{command.method_class.name}"
112
- end
113
-
114
- command.status == 0
158
+ command.status.zero?
115
159
  rescue NoMoreRecordingsError => e
116
160
  raise RecordNotFoundError, e.message
117
161
  end
@@ -135,7 +179,7 @@ module Backspin
135
179
  status: status.exitstatus,
136
180
  recorded_at: Time.now.iso8601
137
181
  )
138
- @commands << command
182
+ record.add_command(command)
139
183
 
140
184
  [stdout, stderr, status]
141
185
  end
@@ -154,7 +198,8 @@ module Backspin
154
198
  args
155
199
  end
156
200
 
157
- stdout, stderr = "", ""
201
+ stdout = ""
202
+ stderr = ""
158
203
  status = result ? 0 : 1
159
204
 
160
205
  command = Command.new(
@@ -165,7 +210,7 @@ module Backspin
165
210
  status: status,
166
211
  recorded_at: Time.now.iso8601
167
212
  )
168
- @commands << command
213
+ record.add_command(command)
169
214
 
170
215
  result
171
216
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Backspin
2
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
3
5
  end
data/lib/backspin.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "yaml"
2
4
  require "fileutils"
3
5
  require "open3"
@@ -50,22 +52,22 @@ module Backspin
50
52
  def default_credential_patterns
51
53
  [
52
54
  # AWS credentials
53
- /AKIA[0-9A-Z]{16}/, # AWS Access Key ID
54
- /aws_secret_access_key\s*[:=]\s*["']?([A-Za-z0-9\/+=]{40})["']?/i, # AWS Secret Key
55
- /aws_session_token\s*[:=]\s*["']?([A-Za-z0-9\/+=]+)["']?/i, # AWS Session Token
55
+ /AKIA[0-9A-Z]{16}/, # AWS Access Key ID
56
+ %r{aws_secret_access_key\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})["']?}i, # AWS Secret Key
57
+ %r{aws_session_token\s*[:=]\s*["']?([A-Za-z0-9/+=]+)["']?}i, # AWS Session Token
56
58
 
57
59
  # Google Cloud credentials
58
- /AIza[0-9A-Za-z\-_]{35}/, # Google API Key
60
+ /AIza[0-9A-Za-z\-_]{35}/, # Google API Key
59
61
  /[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com/, # Google OAuth2 client ID
60
- /-----BEGIN (RSA )?PRIVATE KEY-----/, # Private keys
62
+ /-----BEGIN (RSA )?PRIVATE KEY-----/, # Private keys
61
63
 
62
64
  # Generic patterns
63
- /api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Generic API keys
65
+ /api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Generic API keys
64
66
  /auth[_-]?token\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Auth tokens
65
67
  /Bearer\s+([A-Za-z0-9\-_]+)/, # Bearer tokens
66
- /password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i, # Passwords
67
- /-p([^"'\s]{8,})/, # MySQL-style password args
68
- /secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i # Generic secrets
68
+ /password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i, # Passwords
69
+ /-p([^"'\s]{8,})/, # MySQL-style password args
70
+ /secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i # Generic secrets
69
71
  ]
70
72
  end
71
73
  end
@@ -91,7 +93,6 @@ module Backspin
91
93
  scrubbed = text.dup
92
94
  configuration.credential_patterns.each do |pattern|
93
95
  scrubbed.gsub!(pattern) do |match|
94
- # Replace with asterisks of the same length
95
96
  "*" * match.length
96
97
  end
97
98
  end
@@ -104,22 +105,39 @@ module Backspin
104
105
  # @param options [Hash] Options for recording/verification
105
106
  # @option options [Symbol] :mode (:auto) Recording mode - :auto, :record, :verify, :playback
106
107
  # @option options [Proc] :filter Custom filter for recorded data
107
- # @option options [Proc] :matcher Custom matcher for verification
108
+ # @option options [Proc, Hash] :matcher Custom matcher for verification
109
+ # - Proc: ->(recorded, actual) { ... } for full command matching
110
+ # - Hash: { stdout: ->(recorded, actual) { ... }, stderr: ->(recorded, actual) { ... } } for field-specific matching
111
+ # - Hash with :all key: { all: ->(recorded, actual) { ... }, stdout: ->(recorded, actual) { ... } } for combined matching
112
+ # When both :all and field matchers are present, both must pass for verification to succeed.
113
+ # Fields without specific matchers always use exact equality, regardless of :all presence.
108
114
  # @return [RecordResult] Result object with output and status
109
115
  def run(record_name, options = {}, &block)
110
116
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
111
117
  raise ArgumentError, "block is required" unless block_given?
112
118
 
113
- record_path = build_record_path(record_name)
119
+ record_path = Record.build_record_path(record_name)
114
120
  mode = determine_mode(options[:mode], record_path)
115
121
 
122
+ # Create or load the record based on mode
123
+ record = if mode == :record
124
+ Record.create(record_name)
125
+ else
126
+ Record.load_or_create(record_path)
127
+ end
128
+
129
+ # Create recorder with all needed context
130
+ recorder = Recorder.new(record: record, options: options, mode: mode)
131
+
132
+ # Execute the appropriate mode
116
133
  case mode
117
134
  when :record
118
- perform_recording(record_name, record_path, options, &block)
135
+ recorder.setup_recording_stubs(:capture3, :system)
136
+ recorder.perform_recording(&block)
119
137
  when :verify
120
- perform_verification(record_name, record_path, options, &block)
138
+ recorder.perform_verification(&block)
121
139
  when :playback
122
- perform_playback(record_name, record_path, options, &block)
140
+ recorder.perform_playback(&block)
123
141
  else
124
142
  raise ArgumentError, "Unknown mode: #{mode}"
125
143
  end
@@ -136,7 +154,7 @@ module Backspin
136
154
 
137
155
  if result.verified? == false
138
156
  error_message = "Backspin verification failed!\n"
139
- error_message += "Record: #{result.record_path}\n"
157
+ error_message += "Record: #{result.record.path}\n"
140
158
 
141
159
  # Use the error_message from the result which is now properly formatted
142
160
  error_message += "\n#{result.error_message}" if result.error_message
@@ -155,162 +173,5 @@ module Backspin
155
173
  # Auto mode: record if file doesn't exist, verify if it does
156
174
  File.exist?(record_path) ? :verify : :record
157
175
  end
158
-
159
- def perform_recording(_record_name, record_path, options)
160
- recorder = Recorder.new
161
- recorder.record_calls(:capture3, :system)
162
-
163
- output = yield
164
-
165
- if output.is_a?(Array) && output.size == 3
166
- stdout, stderr, status = output
167
- status_int = status.respond_to?(:exitstatus) ? status.exitstatus : status
168
- output = [stdout, stderr, status_int]
169
- end
170
-
171
- # Save the recording
172
- FileUtils.mkdir_p(File.dirname(record_path))
173
- record = Record.new(record_path)
174
- record.clear
175
- recorder.commands.each { |cmd| record.add_command(cmd) }
176
- record.save(filter: options[:filter])
177
-
178
- # Return result
179
- RecordResult.new(
180
- output: output,
181
- mode: :record,
182
- record_path: Pathname.new(record_path),
183
- commands: recorder.commands
184
- )
185
- end
186
-
187
- def perform_verification(_record_name, record_path, options)
188
- record = Record.load_or_create(record_path)
189
-
190
- raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
191
- raise RecordNotFoundError, "No commands found in record" if record.empty?
192
-
193
- # For verification, we need to track all commands executed
194
- recorder = Recorder.new(mode: :verify, record: record)
195
- recorder.setup_replay_stubs
196
-
197
- # Track verification results for each command
198
- command_diffs = []
199
- command_index = 0
200
-
201
- # Override stubs to verify each command as it's executed
202
- allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
203
- recorded_command = record.commands[command_index]
204
-
205
- if recorded_command.nil?
206
- raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
207
- end
208
-
209
- if recorded_command.method_class != Open3::Capture3
210
- raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got Open3.capture3"
211
- end
212
-
213
- # Execute the actual command
214
- stdout, stderr, status = original_method.call(*args)
215
-
216
- # Create verification result
217
- actual_result = CommandResult.new(
218
- stdout: stdout,
219
- stderr: stderr,
220
- status: status.exitstatus
221
- )
222
-
223
- # Create CommandDiff to track the comparison
224
- command_diffs << CommandDiff.new(
225
- recorded_command: recorded_command,
226
- actual_result: actual_result,
227
- matcher: options[:matcher]
228
- )
229
-
230
- command_index += 1
231
- [stdout, stderr, status]
232
- end
233
-
234
- allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
235
- recorded_command = record.commands[command_index]
236
-
237
- if recorded_command.nil?
238
- raise RecordNotFoundError, "No more recorded commands, but tried to execute: system #{args.inspect}"
239
- end
240
-
241
- if recorded_command.method_class != ::Kernel::System
242
- raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got system"
243
- end
244
-
245
- # Execute the actual command
246
- result = original_method.call(receiver, *args)
247
-
248
- # Create verification result (system only gives us exit status)
249
- actual_result = CommandResult.new(
250
- stdout: "",
251
- stderr: "",
252
- status: result ? 0 : 1
253
- )
254
-
255
- # Create CommandDiff to track the comparison
256
- command_diffs << CommandDiff.new(
257
- recorded_command: recorded_command,
258
- actual_result: actual_result,
259
- matcher: options[:matcher]
260
- )
261
-
262
- command_index += 1
263
- result
264
- end
265
-
266
- # Execute block
267
- output = yield
268
-
269
- # Check if all commands were executed
270
- if command_index < record.commands.size
271
- raise RecordNotFoundError, "Expected #{record.commands.size} commands but only #{command_index} were executed"
272
- end
273
-
274
- # Overall verification status
275
- all_verified = command_diffs.all?(&:verified?)
276
-
277
- RecordResult.new(
278
- output: output,
279
- mode: :verify,
280
- verified: all_verified,
281
- record_path: Pathname.new(record_path),
282
- commands: record.commands,
283
- command_diffs: command_diffs
284
- )
285
- end
286
-
287
- def perform_playback(_record_name, record_path, _options)
288
- record = Record.load_or_create(record_path)
289
-
290
- raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
291
- raise RecordNotFoundError, "No commands found in record" if record.empty?
292
-
293
- # Setup replay mode - this will handle returning values for all commands
294
- recorder = Recorder.new(mode: :replay, record: record)
295
- recorder.setup_replay_stubs
296
-
297
- # Execute block (all commands will be stubbed with recorded values)
298
- output = yield
299
-
300
- RecordResult.new(
301
- output: output,
302
- mode: :playback,
303
- verified: true, # Always true for playback
304
- record_path: Pathname.new(record_path),
305
- commands: record.commands
306
- )
307
- end
308
-
309
- def build_record_path(name)
310
- backspin_dir = configuration.backspin_dir
311
- backspin_dir.mkpath
312
-
313
- File.join(backspin_dir, "#{name}.yml")
314
- end
315
176
  end
316
177
  end
data/release.rake ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ # Simplified release tasks using gem-release
6
+ # Install with: gem install gem-release
7
+
8
+ namespace :release do
9
+ desc "Release a new version (bump, tag, release)"
10
+ task :version, [:level] do |t, args|
11
+ level = args[:level] || "patch"
12
+
13
+ # Pre-release checks
14
+ Rake::Task["release:check"].invoke
15
+
16
+ puts "\nReleasing #{level} version..."
17
+
18
+ # Use gem-release to bump, tag, and release to rubygems and github
19
+ sh "gem bump --version #{level} --github --tag --release"
20
+ end
21
+
22
+ desc "Create GitHub release for current version"
23
+ task :github do
24
+ version = Backspin::VERSION
25
+
26
+ if system("which gh > /dev/null 2>&1")
27
+ puts "\nCreating GitHub release for v#{version}..."
28
+ sh "gh release create v#{version} --title 'Release v#{version}' --generate-notes"
29
+ else
30
+ puts "\nGitHub CLI not found. Create release manually at:"
31
+ puts "https://github.com/rsanheim/backspin/releases/new?tag=v#{version}"
32
+ end
33
+ end
34
+
35
+ desc "Check if ready for release"
36
+ task :check do
37
+ require "open-uri"
38
+ require "json"
39
+
40
+ current_version = Backspin::VERSION
41
+ errors = []
42
+
43
+ # Check RubyGems for latest version
44
+ begin
45
+ gem_data = JSON.parse(URI.open("https://rubygems.org/api/v1/gems/backspin.json").read)
46
+ latest_version = gem_data["version"]
47
+
48
+ if Gem::Version.new(current_version) <= Gem::Version.new(latest_version)
49
+ errors << "Current version (#{current_version}) is not greater than latest released version (#{latest_version})"
50
+ else
51
+ puts "✓ Version #{current_version} is ready for release (latest: #{latest_version})"
52
+ end
53
+ rescue => e
54
+ puts "⚠ Could not check RubyGems version: #{e.message}"
55
+ end
56
+
57
+ # Check git status
58
+ if system("git diff --quiet && git diff --cached --quiet")
59
+ puts "✓ No uncommitted changes"
60
+ else
61
+ errors << "You have uncommitted changes"
62
+ end
63
+
64
+ # Check branch
65
+ current_branch = `git rev-parse --abbrev-ref HEAD`.strip
66
+ if current_branch != "main"
67
+ puts "⚠ Not on main branch (currently on #{current_branch})"
68
+ print "Continue anyway? (y/N): "
69
+ response = $stdin.gets.chomp
70
+ errors << "Not on main branch" unless response.downcase == "y"
71
+ else
72
+ puts "✓ On main branch"
73
+ end
74
+
75
+ unless errors.empty?
76
+ puts "\n❌ Cannot release:"
77
+ errors.each { |e| puts " - #{e}" }
78
+ abort
79
+ end
80
+
81
+ puts "\n✅ All checks passed!"
82
+ end
83
+ end
84
+
85
+ # Convenience tasks
86
+ desc "Release patch version"
87
+ task "release:patch" => ["release:version"]
88
+
89
+ desc "Release minor version"
90
+ task "release:minor" do
91
+ Rake::Task["release:version"].invoke("minor")
92
+ end
93
+
94
+ desc "Release major version"
95
+ task "release:major" do
96
+ Rake::Task["release:version"].invoke("major")
97
+ end
data/script/lint ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Run Standard Ruby linter
5
+ echo "Running Standard Ruby linter..."
6
+ bundle exec standardrb "$@"