saboteur 0.2.0 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
@@ -1,11 +1,15 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "coverage"
4
5
  require "fileutils"
6
+ require "pathname"
7
+ require "English"
8
+ require "tmpdir"
5
9
 
6
10
  module Saboteur
7
11
  module Frameworks
8
- # Opt-in Rails support (`rails: true` in .saboteur.yml).
12
+ # Opt-in Rails support (`c.rails = true` in config/saboteur.rb).
9
13
  #
10
14
  # Refuses to run unless RAILS_ENV is test and every configured database is
11
15
  # local, eager loads the app so every subject's owner is defined before
@@ -14,9 +18,23 @@ module Saboteur
14
18
  class Rails
15
19
  LOCAL_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"].freeze
16
20
 
21
+ # How many blocking session names the template-copy error lists.
22
+ MAX_BLOCKER_NAMES = 5
23
+
24
+ # sqlite3-ruby's Backup#step returns raw SQLite status codes instead
25
+ # of raising, and finish discards its own: without an explicit check
26
+ # a partial destination would pass as a copy.
27
+ SQLITE_DONE = 101
28
+ SQLITE_BUSY = 5
29
+ SQLITE_LOCKED = 6
30
+ BACKUP_ATTEMPTS = 5
31
+ BACKUP_RETRY_DELAY = 0.05
32
+
17
33
  def initialize(config, hooks)
18
34
  @config = config
19
35
  @hooks = hooks
36
+ @worker_copies = []
37
+ @memory_snapshots = {}
20
38
  end
21
39
 
22
40
  def before_boot
@@ -27,18 +45,31 @@ module Saboteur
27
45
  # on macOS that goes through XPC, which crashes a forked process. The
28
46
  # databases are local only (check_databases), so it buys nothing.
29
47
  ENV["PGGSSENCMODE"] ||= "disable"
48
+
49
+ # Bytecode from Bootsnap's compile cache carries no coverage counters,
50
+ # so a file served from the cache never appears in Coverage.result.
51
+ # Turn the cache off for this process before config/boot loads
52
+ # Bootsnap; the load-path cache still works. Bootsnap checks
53
+ # DISABLE_<feature> by presence, so a value the user set disables the
54
+ # cache too. Files loaded during the eager load are also covered by
55
+ # eager_load_with_coverage_running.
56
+ ENV["DISABLE_BOOTSNAP_COMPILE_CACHE"] ||= "1"
30
57
  end
31
58
 
32
59
  def after_boot
33
60
  unless defined?(::Rails.env)
34
61
  raise Error,
35
- "rails: true but Rails did not load (add config/environment to require)"
62
+ "rails: true but Rails did not load (add config/environment to require - for an engine, " \
63
+ "its dummy app's environment, such as test/dummy/config/environment.rb; only a dynamically " \
64
+ "created dummy app has no environment file, and there you require the helper that calls " \
65
+ "setup instead, such as DummyApp.setup in spec/rails_helper)"
36
66
  end
37
67
  raise Error, "rails integration refuses to run: Rails.env is #{::Rails.env}" unless ::Rails.env.test?
38
68
 
39
69
  check_databases
40
- ::Rails.application.eager_load!
70
+ eager_load_with_coverage_running
41
71
  disconnect
72
+ snapshot_memory_databases
42
73
  @hooks.register(:before_fork) { |_mutation| disconnect }
43
74
  @hooks.register(:after_fork) { reconnect }
44
75
  end
@@ -54,16 +85,58 @@ module Saboteur
54
85
  end
55
86
 
56
87
  # Parent, before workers start: one copy of each test database per
57
- # extra worker, made from the prepared test database.
88
+ # extra worker, made from the prepared test database. Every attempted
89
+ # copy is tracked before it runs, so cleanup_workers drops them all,
90
+ # including the partial set when a copy failed.
58
91
  def prepare_workers(jobs)
59
92
  return unless defined?(::ActiveRecord::Base) && jobs > 1
60
93
 
94
+ @worker_copies = []
61
95
  test_configs.each do |db|
62
- (1...jobs).each { |number| copy_database(db, self.class.worker_database(db.database, number)) }
96
+ # An in-memory SQLite database lives in the parent's connection;
97
+ # workers inherit it through fork, so there is nothing to copy.
98
+ next if self.class.memory_database?(db)
99
+
100
+ (1...jobs).each do |number|
101
+ name = self.class.worker_database(db.database, number)
102
+ # Track the target before the copy, not after it: an attempt
103
+ # that created the database and then failed (a post-CREATE
104
+ # error, a partial SQLite copy) must still be dropped at the
105
+ # end of the run. Dropping is idempotent (DROP IF EXISTS,
106
+ # rm -f), so tracking a copy that never happened costs nothing.
107
+ @worker_copies << [db, name]
108
+ copy_database(db, name)
109
+ end
63
110
  end
64
111
  disconnect
65
112
  end
66
113
 
114
+ # Parent, after the run: drop every copy prepare_workers made, so runs
115
+ # do not collect one database per worker. Every copy is attempted even
116
+ # when a drop fails; the failures are reported together at the end.
117
+ def cleanup_workers
118
+ return if @worker_copies.empty? && @memory_snapshot_dir.nil?
119
+
120
+ failures = @worker_copies.filter_map do |db, name|
121
+ drop_database(db, name)
122
+ nil
123
+ rescue StandardError => e
124
+ "#{name}: #{e.message}"
125
+ end
126
+ @worker_copies = []
127
+ if @memory_snapshot_dir
128
+ FileUtils.rm_rf(@memory_snapshot_dir)
129
+ @memory_snapshot_dir = nil
130
+ @memory_snapshots = {}
131
+ end
132
+ begin
133
+ disconnect
134
+ rescue StandardError => e
135
+ failures << "disconnect: #{e.message}"
136
+ end
137
+ raise Error, "could not drop worker database copies: #{failures.join('; ')}" if failures.any?
138
+ end
139
+
67
140
  # Worker: point every connection pool (primary, and any cache, queue or
68
141
  # connects_to databases, for every role and shard) at this worker's
69
142
  # copy of its database.
@@ -74,11 +147,16 @@ module Saboteur
74
147
  handler.connection_pool_list(:all).to_a.each do |pool|
75
148
  config = pool.pool_config
76
149
  db = pool.db_config
150
+ # An in-memory SQLite database would be discarded by
151
+ # re-establishing: the worker keeps its own connection.
152
+ next if self.class.memory_database?(db)
153
+
77
154
  handler.establish_connection(db.configuration_hash.merge(database: self.class.worker_database(db.database,
78
155
  number)),
79
156
  owner_name: self.class.owner_of(config), role: config.role,
80
157
  shard: config.shard)
81
158
  end
159
+ restore_memory_databases
82
160
  end
83
161
 
84
162
  def self.owner_of(pool_config)
@@ -87,12 +165,35 @@ module Saboteur
87
165
  Object.const_get(pool_config.connection_descriptor.name)
88
166
  end
89
167
 
168
+ # Only SQLite can be in-memory; every other adapter names a server.
169
+ def self.memory_database?(db)
170
+ db.adapter.to_s.match?(/sqlite/) && db.database.to_s == ":memory:"
171
+ end
172
+
90
173
  def self.local_host?(host)
91
174
  host.nil? || host.to_s.empty? || host.to_s.start_with?("/") || LOCAL_HOSTS.include?(host.to_s.downcase)
92
175
  end
93
176
 
94
177
  private
95
178
 
179
+ # Bootsnap before 1.25 skips its bytecode cache only while Coverage is
180
+ # running (1.25 and later skip it whenever Coverage is set up, suspended
181
+ # or not), and Coverage is suspended while the app boots (exe/saboteur
182
+ # sets it up suspended). Cached bytecode carries no coverage counters.
183
+ # Resume Coverage just for the eager load, so every app file compiles
184
+ # with counters. The coverage recorder clears the counts before the
185
+ # suite runs, so load-time hits are never credited to a test.
186
+ def eager_load_with_coverage_running
187
+ return ::Rails.application.eager_load! unless Coverage.state == :suspended
188
+
189
+ Coverage.resume
190
+ begin
191
+ ::Rails.application.eager_load!
192
+ ensure
193
+ Coverage.suspend
194
+ end
195
+ end
196
+
96
197
  def check_databases
97
198
  return unless defined?(::ActiveRecord::Base)
98
199
 
@@ -118,29 +219,157 @@ module Saboteur
118
219
  disconnect
119
220
  copy_postgres(db, name)
120
221
  when /sqlite/
121
- # A database in WAL mode keeps recent writes in -wal/-shm files.
122
- ["", "-wal", "-shm"].each do |suffix|
123
- FileUtils.cp("#{db.database}#{suffix}", "#{name}#{suffix}") if File.exist?("#{db.database}#{suffix}")
124
- end
222
+ copy_sqlite(db, name)
125
223
  else
126
224
  raise Error, "--jobs > 1 with Rails needs Postgres or SQLite (got #{db.adapter})"
127
225
  end
128
226
  end
129
227
 
228
+ # The mirror of copy_database. Adapters copy_database rejects never get
229
+ # copies, so there is nothing to drop for them.
230
+ def drop_database(db, name)
231
+ case db.adapter
232
+ when /postg/
233
+ disconnect
234
+ drop_postgres(db, name)
235
+ when /sqlite/
236
+ target = sqlite_absolute(name)
237
+ ["", "-wal", "-shm"].each { |suffix| FileUtils.rm_f("#{target}#{suffix}") }
238
+ end
239
+ end
240
+
241
+ # A relative sqlite path means something only to Rails: 7.1+ resolves
242
+ # it against Rails.root, not the process working directory. Resolve
243
+ # the source the same way, checkpoint so a live WAL database holds
244
+ # every committed write in its main file, then snapshot it through
245
+ # the backup API. A missing source is an error, not a skip: otherwise
246
+ # the worker starts from a fresh empty database and silently tests
247
+ # nothing.
248
+ def copy_sqlite(db, name)
249
+ source = sqlite_absolute(db.database)
250
+ unless File.exist?(source)
251
+ raise Error, "test database #{source} does not exist; run the test suite once to create it"
252
+ end
253
+
254
+ checkpoint_sqlite(source)
255
+ backup_sqlite(source, sqlite_absolute(name))
256
+ end
257
+
258
+ def sqlite_absolute(database)
259
+ return database if Pathname.new(database).absolute?
260
+ return database unless defined?(::Rails)
261
+
262
+ File.expand_path(database, ::Rails.root)
263
+ end
264
+
265
+ # wal_checkpoint(TRUNCATE) reports instead of raising: [busy, frames
266
+ # in the WAL, frames checkpointed]. A busy reader or an incomplete
267
+ # pass leaves committed writes outside the main file, and copying on
268
+ # would silently miss them - fail closed instead. Through a
269
+ # connection of saboteur's own, so the app's connections never point
270
+ # at the database being checkpointed.
271
+ def checkpoint_sqlite(source)
272
+ with_sqlite(source) do |database|
273
+ busy, frames, checkpointed = database.execute("PRAGMA wal_checkpoint(TRUNCATE)").first
274
+ return if busy.zero? && frames == checkpointed
275
+
276
+ raise Error, "could not checkpoint #{source} before copying it " \
277
+ "(busy: #{busy}, frames: #{frames}, checkpointed: #{checkpointed}); " \
278
+ "close the connections holding it and run again"
279
+ end
280
+ rescue SQLite3::BusyException => e
281
+ raise Error, "could not checkpoint #{source} before copying it (#{e.message}); " \
282
+ "close the connections holding it and run again"
283
+ end
284
+
285
+ # A byte copy of main + WAL + SHM is no snapshot: a write landing
286
+ # between the file copies leaves the set malformed. The backup API
287
+ # copies a consistent image and restarts when the source changes
288
+ # under it. The target starts clean so stale sidecars from an
289
+ # earlier byte copy cannot shadow the fresh image.
290
+ def backup_sqlite(source, target)
291
+ ["", "-wal", "-shm"].each { |suffix| FileUtils.rm_f("#{target}#{suffix}") }
292
+ with_sqlite(source, readonly: true) do |src|
293
+ with_sqlite(target) do |dst|
294
+ backup = SQLite3::Backup.new(dst, "main", src, "main")
295
+ begin
296
+ step_backup(backup, target)
297
+ ensure
298
+ backup.finish
299
+ end
300
+ end
301
+ end
302
+ end
303
+
304
+ # BUSY and LOCKED earn a bounded retry; DONE means the target holds
305
+ # every page; anything else fails the copy.
306
+ def step_backup(backup, target)
307
+ BACKUP_ATTEMPTS.times do
308
+ case (status = backup.step(-1))
309
+ when SQLITE_DONE then return
310
+ when SQLITE_BUSY, SQLITE_LOCKED
311
+ sleep(BACKUP_RETRY_DELAY)
312
+ else
313
+ raise Error, "could not copy the database to #{target}: sqlite backup failed with status #{status}"
314
+ end
315
+ end
316
+ raise Error, "could not copy the database to #{target}: still busy after #{BACKUP_ATTEMPTS} attempts"
317
+ end
318
+
319
+ def with_sqlite(path, **)
320
+ require "sqlite3"
321
+
322
+ database = SQLite3::Database.new(path, **)
323
+ begin
324
+ yield database
325
+ ensure
326
+ database.close
327
+ end
328
+ end
329
+
130
330
  # Through a connection class of saboteur's own, so the app's
131
331
  # connections never point at the maintenance database.
132
332
  def copy_postgres(db, name)
133
- admin = maintenance_record
134
- admin.establish_connection(db.configuration_hash.merge(database: "postgres"))
135
- admin.connection_pool.with_connection do |connection|
333
+ with_maintenance_connection(db) do |connection|
136
334
  connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(name)}")
335
+ refuse_busy_template(connection, db.database)
137
336
  connection.execute("CREATE DATABASE #{connection.quote_table_name(name)} " \
138
337
  "TEMPLATE #{connection.quote_table_name(db.database)}")
139
338
  end
339
+ end
340
+
341
+ def drop_postgres(db, name)
342
+ with_maintenance_connection(db) do |connection|
343
+ connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(name)}")
344
+ end
345
+ end
346
+
347
+ # Through a connection class of saboteur's own, so the app's
348
+ # connections never point at the maintenance database.
349
+ def with_maintenance_connection(db, &)
350
+ admin = maintenance_record
351
+ admin.establish_connection(db.configuration_hash.merge(database: "postgres"))
352
+ admin.connection_pool.with_connection(&)
140
353
  ensure
141
354
  admin&.remove_connection
142
355
  end
143
356
 
357
+ # Postgres refuses CREATE DATABASE ... TEMPLATE while any other session
358
+ # is connected to the template, so name the blockers instead of dying
359
+ # mid-run with the raw error. Never terminates other sessions.
360
+ def refuse_busy_template(connection, database)
361
+ blockers = connection.select_values(<<~SQL)
362
+ SELECT application_name FROM pg_stat_activity
363
+ WHERE datname = #{connection.quote(database)} AND pid <> pg_backend_pid()
364
+ SQL
365
+ return if blockers.empty?
366
+
367
+ names = blockers.map { |app| app.to_s.empty? ? "unnamed" : app }.first(MAX_BLOCKER_NAMES)
368
+ raise Error, "cannot copy the test database #{database}: #{blockers.size} other session(s) connected " \
369
+ "(#{names.join(', ')})#{', ...' if blockers.size > names.size}. " \
370
+ "Stop other test runs, consoles or Spring, then run saboteur again."
371
+ end
372
+
144
373
  # ActiveRecord names a connection's owner by class name, so the class
145
374
  # needs a constant.
146
375
  def maintenance_record
@@ -149,10 +378,15 @@ module Saboteur
149
378
  self.class.const_set(:MaintenanceRecord, Class.new(::ActiveRecord::Base) { self.abstract_class = true })
150
379
  end
151
380
 
381
+ # Disconnecting an in-memory SQLite pool destroys the database, so
382
+ # those pools stay connected everywhere saboteur disconnects: the
383
+ # schema loaded at boot survives, and workers inherit it via fork.
152
384
  def disconnect
153
385
  return unless defined?(::ActiveRecord::Base)
154
386
 
155
- ::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each(&:disconnect!)
387
+ ::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
388
+ pool.disconnect! unless self.class.memory_database?(pool.db_config)
389
+ end
156
390
  end
157
391
 
158
392
  # The parent disconnected before fork, so inherited pools hold no sockets.
@@ -160,6 +394,88 @@ module Saboteur
160
394
  # checks out a fresh connection of its own on first use.
161
395
  def reconnect
162
396
  disconnect
397
+ restore_memory_databases
398
+ end
399
+
400
+ # Parent, once the app booted: an in-memory SQLite database survives
401
+ # only inside its own connection, and a forked child cannot keep it -
402
+ # ActiveRecord discards inherited pools and the sqlite3 gem closes
403
+ # writable handles in the child, so the next access there opens an
404
+ # empty database. Snapshot each in-memory database to a file the
405
+ # children can restore from.
406
+ def snapshot_memory_databases
407
+ return unless defined?(::ActiveRecord::Base)
408
+
409
+ pools = ::ActiveRecord::Base.connection_handler.connection_pool_list(:all)
410
+ memory_pools = pools.select { |pool| self.class.memory_database?(pool.db_config) }
411
+ return if memory_pools.empty?
412
+
413
+ @memory_snapshot_dir = Dir.mktmpdir("saboteur-memory-")
414
+ own_memory_snapshots
415
+ memory_pools.each_with_index do |pool, index|
416
+ path = File.join(@memory_snapshot_dir, "#{index}.sqlite3")
417
+ pool.with_connection do |connection|
418
+ file = SQLite3::Database.new(path)
419
+ with_backup(file, connection.raw_connection, close: file) { |backup| step_backup(backup, path) }
420
+ end
421
+ @memory_snapshots[pool.db_config] = path
422
+ end
423
+ end
424
+
425
+ # The safety net for an abrupt process exit: Engine.call removes the
426
+ # snapshots at the run boundary on every normal path, so this handler
427
+ # fires only when the owner process dies first. Forked children
428
+ # inherit the handler but not the pid.
429
+ def own_memory_snapshots
430
+ owner = Process.pid
431
+ at_exit { cleanup_memory_snapshots(owner) }
432
+ end
433
+
434
+ def cleanup_memory_snapshots(owner)
435
+ FileUtils.rm_rf(@memory_snapshot_dir) if Process.pid == owner && @memory_snapshot_dir
436
+ end
437
+
438
+ # Yields a backup and always returns both handles: exactly one finish
439
+ # per init (skipped only when backup_init itself raised, leaving no
440
+ # handle), and the database closes even when finish fails.
441
+ def with_backup(destination, source, close:)
442
+ backup = SQLite3::Backup.new(destination, "main", source, "main")
443
+ yield backup
444
+ ensure
445
+ begin
446
+ backup&.finish
447
+ ensure
448
+ close_without_masking(close)
449
+ end
450
+ end
451
+
452
+ # A close can fail too, and a close that fails on the way out must
453
+ # not hide the error already in flight.
454
+ def close_without_masking(database)
455
+ in_flight = $ERROR_INFO
456
+ database.close
457
+ rescue SQLite3::Exception
458
+ raise unless in_flight
459
+ end
460
+
461
+ # Child: load the parent's snapshot into this process's own fresh
462
+ # in-memory database. The backup overwrites the destination, so
463
+ # restoring more than once is harmless.
464
+ def restore_memory_databases
465
+ return unless defined?(::ActiveRecord::Base)
466
+ return if @memory_snapshots.empty?
467
+
468
+ ::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
469
+ snapshot = @memory_snapshots[pool.db_config]
470
+ next unless snapshot
471
+
472
+ pool.with_connection do |connection|
473
+ source = SQLite3::Database.new(snapshot, readonly: true)
474
+ with_backup(connection.raw_connection, source, close: source) do |backup|
475
+ step_backup(backup, pool.db_config.database)
476
+ end
477
+ end
478
+ end
163
479
  end
164
480
  end
165
481
  end
@@ -0,0 +1,210 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "config"
5
+
6
+ module Saboteur
7
+ # The text printed by `saboteur --help` and `saboteur help <command>`.
8
+ #
9
+ # It is a map of everything saboteur can do, for people and for AI agents that
10
+ # have no docs. The layout is fixed: section headers are upper case on their
11
+ # own line, command and key rows start with two spaces. Option lines come from
12
+ # the CLI's own option parser, so a new option shows up here without an edit.
13
+ class Help
14
+ Command = Data.define(:name, :summary, :description, :takes_options, :exit_codes, :examples)
15
+
16
+ PURPOSE = "mutation testing for Ruby and RSpec"
17
+ DOCUMENTATION = "https://github.com/vamsipavanmahesh/saboteur"
18
+
19
+ EXIT_CODE_MEANINGS = {
20
+ 0 => "Success. Without --threshold: no unignored survivors, and at most half of the executed mutants " \
21
+ "errored. With --threshold: the score is at or above it, even if mutants survived.",
22
+ 1 => "Without --threshold: at least one mutant survived and is not on the ignore list. With --threshold " \
23
+ "(survivors alone no longer fail the run): the score is below it, or no mutant was tested so " \
24
+ "there is no score.",
25
+ 2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
26
+ "mutant is only found after the target has booted. A named source or test file that does not exist.",
27
+ 3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
28
+ "a run tested almost nothing). With --threshold, also any errored or resource-limited mutant."
29
+ }.freeze
30
+
31
+ DOCTOR_EXIT_CODE_MEANINGS = {
32
+ 0 => "Healthy: the environment checks passed and the baseline passed twice in the same order.",
33
+ 2 => "Usage or configuration error: fix the command or config/saboteur.rb.",
34
+ 3 => "A check failed: the target did not boot, or the baseline failed or changed between runs."
35
+ }.freeze
36
+
37
+ COMMANDS = [
38
+ Command.new(
39
+ name: "run",
40
+ summary: "Mutate your code and report the mutants your tests do not catch",
41
+ description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
42
+ "tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
43
+ "directories after run replace include and exclude; --test limits the specs that run; " \
44
+ "--threshold PERCENT replaces the survivor check: exit 0 when the score is at or above " \
45
+ "PERCENT, exit 1 when it is below.",
46
+ takes_options: true,
47
+ exit_codes: EXIT_CODE_MEANINGS,
48
+ examples: [
49
+ ["Pull request: only methods changed since the target branch", "saboteur run --since origin/main"],
50
+ ["Whole project, four workers, repeatable seed", "saboteur run --jobs 4 --seed 42"],
51
+ ["One class or method", "saboteur run --subject 'Person#adult?'"],
52
+ ["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
53
+ ["Mutate named files against named specs, skipping the whole-suite coverage run",
54
+ "saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb"],
55
+ ["CI gate: exit 1 when the score is below 90 percent", "saboteur run --threshold 90"],
56
+ ["AI agent: bounded JSON summary and strict exit codes", "saboteur run --agent --since origin/main"]
57
+ ]
58
+ ),
59
+ Command.new(
60
+ name: "doctor",
61
+ summary: "Check the environment and run the baseline twice, without mutating anything",
62
+ description: "Boots the target like a run, checks runtime, coverage and boot files, then runs your " \
63
+ "suite twice in the same order with one seed and reports whether both runs passed. " \
64
+ "It never mutates anything.",
65
+ takes_options: true,
66
+ exit_codes: DOCTOR_EXIT_CODE_MEANINGS,
67
+ examples: [["Check the setup before the first run", "saboteur doctor"],
68
+ ["Use another config file and seed", "saboteur doctor --config config/ci.rb --seed 7"]]
69
+ ),
70
+ Command.new(
71
+ name: "version",
72
+ summary: "Print the installed version",
73
+ description: "Prints the version number and nothing else.",
74
+ takes_options: false,
75
+ exit_codes: nil,
76
+ examples: [["Print the version", "saboteur version"]]
77
+ ),
78
+ Command.new(
79
+ name: "help",
80
+ summary: "Show this map, or details for one command: saboteur help <command>",
81
+ description: "Without an argument, prints every command, option, config key and exit code. " \
82
+ "With a command name, prints that command's usage, options and examples.",
83
+ takes_options: false,
84
+ exit_codes: nil,
85
+ examples: [["Show everything", "saboteur help"], ["Show one command", "saboteur help run"]]
86
+ ),
87
+ Command.new(
88
+ name: "agent-skill",
89
+ summary: "Print instructions for AI coding agents to paste into AGENTS.md or CLAUDE.md",
90
+ description: "Prints a short Markdown block that tells an agent when to run saboteur, how to read " \
91
+ "`saboteur run --agent`, and how to handle survivors.",
92
+ takes_options: false,
93
+ exit_codes: nil,
94
+ examples: [["Add the agent instructions to your repo", "saboteur agent-skill >> AGENTS.md"]]
95
+ )
96
+ ].freeze
97
+
98
+ AGENT_EXIT_CODES = " With --agent the codes are stricter: 0 only when the run was complete and clean; " \
99
+ "1 survivors to review;\n 2 usage or config error; 3 incomplete (no coverage, caps, errors, " \
100
+ "nothing selected) or tool failure."
101
+
102
+ FILES = [
103
+ ["config/saboteur.rb", "Settings as Ruby (Saboteur.configure). Command line options override it."],
104
+ ["config/saboteur_ignore.rb", "Accepted survivors: mutation digests with subject and a required reason."],
105
+ [".saboteur/report.json", "Stryker mutation-testing-report-schema JSON (--json changes the path)."],
106
+ [".saboteur/report.html", "Self-contained HTML report (--html changes the path)."],
107
+ [".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete."]
108
+ ].freeze
109
+
110
+ # option_lines: the option lines of each command's parser, keyed by command name.
111
+ def initialize(option_lines:, commands: COMMANDS)
112
+ @option_lines = option_lines
113
+ @commands = commands
114
+ end
115
+
116
+ def overview
117
+ [title, usage_section, commands_section, *option_sections, examples_section(all_examples),
118
+ *exit_code_sections, config_section, files_section, "Documentation: #{DOCUMENTATION}\n"].join("\n")
119
+ end
120
+
121
+ # Details for one command, or nil when there is no such command.
122
+ def command(name)
123
+ found = @commands.find { |candidate| candidate.name == name }
124
+ return unless found
125
+
126
+ sections = ["saboteur #{found.name} - #{found.summary}\n", "#{found.description}\n", command_usage(found)]
127
+ sections << options_section(found) if found.takes_options
128
+ sections << examples_section(found.examples)
129
+ sections << exit_codes_section(found.exit_codes) if found.exit_codes
130
+ sections.join("\n")
131
+ end
132
+
133
+ def command_names
134
+ @commands.map(&:name)
135
+ end
136
+
137
+ private
138
+
139
+ # One section for every command that takes options, so the overview needs no other page.
140
+ def option_sections
141
+ @commands.select(&:takes_options).map { |command| options_section(command) }
142
+ end
143
+
144
+ def exit_code_sections
145
+ @commands.select(&:exit_codes).map { |command| exit_codes_section(command.exit_codes, command.name) }
146
+ end
147
+
148
+ def title
149
+ "saboteur #{VERSION} - #{PURPOSE}\n"
150
+ end
151
+
152
+ def usage_section
153
+ "USAGE\n saboteur <command> [options]\n saboteur help [command]\n"
154
+ end
155
+
156
+ def command_usage(command)
157
+ suffix = command.takes_options ? " [options]" : ""
158
+ "USAGE\n saboteur #{command.name}#{suffix}\n"
159
+ end
160
+
161
+ def commands_section
162
+ rows = @commands.map { |command| " #{command.name.ljust(name_width)} #{command.summary}" }
163
+ "COMMANDS\n#{rows.join("\n")}\n"
164
+ end
165
+
166
+ def name_width
167
+ @commands.map { |command| command.name.length }.max
168
+ end
169
+
170
+ def options_section(command)
171
+ "OPTIONS (saboteur #{command.name})\n#{@option_lines.fetch(command.name).map(&:rstrip).join("\n")}\n"
172
+ end
173
+
174
+ def all_examples
175
+ @commands.flat_map(&:examples)
176
+ end
177
+
178
+ def examples_section(examples)
179
+ "EXAMPLES\n#{examples.map { |note, line| " # #{note}\n #{line}" }.join("\n")}\n"
180
+ end
181
+
182
+ def exit_codes_section(codes, command_name = nil)
183
+ rows = codes.map { |code, meaning| " #{code} #{meaning}" }
184
+ title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
185
+ note = codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
186
+ "#{title}\n#{rows.join("\n")}\n#{note}"
187
+ end
188
+
189
+ def config_section
190
+ rows = Config::DEFAULTS.map { |key, value| " c.#{key} = #{describe_default(value)}" }
191
+ intro = "Every setting with its default value, as a Ruby assignment. Unknown settings are an error."
192
+ "CONFIG FILE (config/saboteur.rb)\n#{intro}\n#{rows.join("\n")}\n"
193
+ end
194
+
195
+ # Ruby literals, so a copied line loads as the same value: inspect prints
196
+ # nil as "nil" (JSON would print null, which is not Ruby) and quotes
197
+ # strings and arrays the way Ruby reads them back.
198
+ def describe_default(value)
199
+ value.inspect
200
+ end
201
+
202
+ def files_section
203
+ "FILES\n#{FILES.map { |path, meaning| " #{path.ljust(files_width)} #{meaning}" }.join("\n")}\n"
204
+ end
205
+
206
+ def files_width
207
+ FILES.map { |path, _| path.length }.max
208
+ end
209
+ end
210
+ end