saboteur 0.8.0 → 0.8.1

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -0
  3. data/README.md +34 -6
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/presets.rb +41 -1
  6. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  7. data/lib/saboteur/cli/source_set.rb +0 -18
  8. data/lib/saboteur/cli.rb +4 -2
  9. data/lib/saboteur/config.rb +4 -1
  10. data/lib/saboteur/config_file.rb +12 -8
  11. data/lib/saboteur/custom_map.rb +12 -1
  12. data/lib/saboteur/dirty_tree_warning.rb +5 -3
  13. data/lib/saboteur/doctor.rb +4 -0
  14. data/lib/saboteur/engine/baseline_guard.rb +76 -10
  15. data/lib/saboteur/engine/boot.rb +7 -3
  16. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  17. data/lib/saboteur/engine/candidate_selection.rb +1 -0
  18. data/lib/saboteur/engine/coverage_store.rb +25 -3
  19. data/lib/saboteur/engine/diagnostics.rb +1 -1
  20. data/lib/saboteur/engine/gated_run.rb +3 -1
  21. data/lib/saboteur/engine/load_coverage.rb +18 -0
  22. data/lib/saboteur/engine/pipelines.rb +8 -0
  23. data/lib/saboteur/engine/run_budget.rb +5 -2
  24. data/lib/saboteur/engine/sandbox_policy.rb +21 -3
  25. data/lib/saboteur/engine/scheduler.rb +32 -24
  26. data/lib/saboteur/engine/selection_announcement.rb +8 -3
  27. data/lib/saboteur/engine/subject_selection.rb +115 -31
  28. data/lib/saboteur/engine/test_guess.rb +106 -16
  29. data/lib/saboteur/engine.rb +3 -2
  30. data/lib/saboteur/execution/external_runner.rb +18 -0
  31. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  32. data/lib/saboteur/execution/marker_sweep.rb +2 -1
  33. data/lib/saboteur/execution/process_table.rb +7 -3
  34. data/lib/saboteur/execution/reevaluator.rb +7 -0
  35. data/lib/saboteur/execution/sandbox.rb +10 -4
  36. data/lib/saboteur/execution/seatbelt.rb +10 -3
  37. data/lib/saboteur/execution/supervisor.rb +2 -1
  38. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  39. data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
  40. data/lib/saboteur/integration/rspec.rb +99 -11
  41. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  42. data/lib/saboteur/mutation_source/builtin.rb +3 -1
  43. data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
  44. data/lib/saboteur/mutation_source/operator_examples.rb +1 -0
  45. data/lib/saboteur/mutation_source/operator_registry.rb +1 -0
  46. data/lib/saboteur/preview.rb +5 -0
  47. data/lib/saboteur/reporter/agent_incomplete.rb +20 -5
  48. data/lib/saboteur/reporter/terminal.rb +16 -1
  49. data/lib/saboteur/selection/coverage_map.rb +17 -2
  50. data/lib/saboteur/version.rb +1 -1
  51. data/lib/saboteur.rb +1 -0
  52. metadata +5 -15
@@ -16,12 +16,15 @@ module Saboteur
16
16
  reasons = []
17
17
  shard_reason(reasons, report)
18
18
  add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
19
- load_only = report.counts[:ran_at_load] + report.skipped_subjects.values.count do |reason|
20
- reason == Selection::CoverageMap::RAN_AT_LOAD
21
- end
22
19
  add(reasons, "unsupported", unsupported_subjects(report),
23
20
  "subjects were skipped as unsupported (for example defined inside a block); no mutant was judged")
24
- add(reasons, "ran_at_load", load_only, "code ran only at load; no mutant was judged")
21
+ add(reasons, "ran_at_load", load_only_subjects(report), "code ran only at load; no mutant was judged")
22
+ # A file loaded before Coverage.start has no attribution at all:
23
+ # not unsupported (the shape is fine) and not ran_at_load (the
24
+ # load-only evidence does not exist) - its own code (#688).
25
+ add(reasons, "preloaded", preloaded_subjects(report),
26
+ "files loaded before coverage started; their subjects could not be attributed to tests; " \
27
+ "no mutant was judged")
25
28
  add(reasons, "unreachable", report.counts[:unreachable], "tests did not call the replacement definition")
26
29
  add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
27
30
  add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
@@ -80,9 +83,21 @@ module Saboteur
80
83
  report.skipped_subjects.values.count(NO_COVERAGE) + report.counts[:skipped]
81
84
  end
82
85
 
86
+ # Load-only mutants plus the subjects skipped for the same reason.
87
+ def load_only_subjects(report)
88
+ report.counts[:ran_at_load] + report.skipped_subjects.values.count do |reason|
89
+ reason == Selection::CoverageMap::RAN_AT_LOAD
90
+ end
91
+ end
92
+
93
+ def preloaded_subjects(report)
94
+ report.skipped_subjects.values.count(Selection::CoverageMap::PRELOADED)
95
+ end
96
+
83
97
  def unsupported_subjects(report)
84
98
  report.skipped_subjects.values.count do |reason|
85
- reason != NO_COVERAGE && reason != Selection::CoverageMap::RAN_AT_LOAD
99
+ reason != NO_COVERAGE && reason != Selection::CoverageMap::RAN_AT_LOAD &&
100
+ reason != Selection::CoverageMap::PRELOADED
86
101
  end
87
102
  end
88
103
 
@@ -87,9 +87,18 @@ module Saboteur
87
87
  end
88
88
  end
89
89
 
90
+ # An early stop runs fewer mutants than the selection: say both
91
+ # numbers so "4 mutants" is not read as the whole plan (#582).
92
+ def mutant_counts(report)
93
+ planned = report.early_stop&.planned_mutants
94
+ return "#{report.results.size} mutants" unless planned && planned > report.results.size
95
+
96
+ "#{planned} mutants selected, #{report.results.size} attempted"
97
+ end
98
+
90
99
  def summary(report)
91
100
  seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
92
- @out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
101
+ @out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{mutant_counts(report)}, " \
93
102
  "wall #{format('%.2f', report.wall_time)} s#{seed}"
94
103
  @out.puts report.shard.line if report.shard
95
104
  @out.puts status_line(report)
@@ -258,6 +267,12 @@ module Saboteur
258
267
  def budget_shortfall(stop, completed)
259
268
  return unless stop.reason == "max_duration"
260
269
 
270
+ # Exit 4 alone does not say whether anything was judged: headline
271
+ # it when the baselines and coverage map ate the whole budget (#637).
272
+ if completed.zero?
273
+ @out.puts @palette.paint(:yellow, "no mutants ran before the --max-duration budget was spent; " \
274
+ "there is no score to judge")
275
+ end
261
276
  line = if stop.planned_mutants
262
277
  "partial: #{stop.planned_mutants - completed} of #{stop.planned_mutants} mutants not run"
263
278
  else
@@ -20,9 +20,10 @@ module Saboteur
20
20
  FORMAT = 11
21
21
 
22
22
  RAN_AT_LOAD = "ran at load: the code ran while the app loaded; a forked test cannot observe a change here"
23
+ PRELOADED = "loaded before coverage started"
23
24
 
24
25
  attr_reader :lines, :tests, :build_seconds, :loaded_files, :durations, :methods, :load_lines, :load_methods,
25
- :branches
26
+ :branches, :preloaded
26
27
 
27
28
  # lines: { "/abs/file.rb" => { 12 => ["id1", "id2"] } }
28
29
  # methods: { "/abs/file.rb" => { 12 => ["id1", "id2"] } } keyed by the
@@ -30,7 +31,7 @@ module Saboteur
30
31
  # so methods carry those subjects (issue #107).
31
32
  # durations: { "id1" => seconds }
32
33
  def initialize(lines:, tests:, build_seconds: nil, durations: {}, methods: {}, load_lines: {}, load_methods: {},
33
- branches: {}, loaded_files: nil)
34
+ branches: {}, loaded_files: nil, preloaded: [])
34
35
  @lines = lines
35
36
  @tests = tests
36
37
  @build_seconds = build_seconds
@@ -40,6 +41,7 @@ module Saboteur
40
41
  @load_methods = load_methods
41
42
  @branches = branches
42
43
  @loaded_files = loaded_files || []
44
+ @preloaded = preloaded.to_set { |file| CoverageMap.canonical(file) }
43
45
  end
44
46
 
45
47
  # Source files the given tests touched, sorted and deduped: the
@@ -99,6 +101,19 @@ module Saboteur
99
101
  loaded_files: loaded_files)
100
102
  end
101
103
 
104
+ # Files that loaded before Coverage.start (gemspec require, RUBYOPT
105
+ # -r): they can never carry hits, so their subjects are reported as
106
+ # loaded before coverage started instead of no coverage.
107
+ def with_preloaded(paths)
108
+ self.class.new(lines: lines, tests: tests, build_seconds: build_seconds, durations: durations,
109
+ methods: methods, load_lines: load_lines, load_methods: load_methods, branches: branches,
110
+ loaded_files: loaded_files, preloaded: paths)
111
+ end
112
+
113
+ def preloaded?(file)
114
+ preloaded.include?(CoverageMap.canonical(file))
115
+ end
116
+
102
117
  # A def the boot called (a def-time macro's class-body caller, say)
103
118
  # and no test or hook called again: re-evaluating it post-boot
104
119
  # changes nothing a selected test can observe, because closure
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.8.0"
5
+ VERSION = "0.8.1"
6
6
  end
data/lib/saboteur.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "saboteur/integration/base"
51
51
  require_relative "saboteur/subject"
52
52
  require_relative "saboteur/refinement_activations"
53
53
  require_relative "saboteur/subject_finder"
54
+ require_relative "saboteur/attribute_finder"
54
55
  require_relative "saboteur/constant_subject"
55
56
  require_relative "saboteur/constant_subject_finder"
56
57
  require_relative "saboteur/constant_reads"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
@@ -10,20 +10,6 @@ bindir: exe
10
10
  cert_chain: []
11
11
  date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: fiddle
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '1.1'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '1.1'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: prism
29
15
  requirement: !ruby/object:Gem::Requirement
@@ -61,6 +47,7 @@ files:
61
47
  - lib/generators/saboteur/install/install_generator.rb
62
48
  - lib/saboteur.rb
63
49
  - lib/saboteur/agent_skill.rb
50
+ - lib/saboteur/attribute_finder.rb
64
51
  - lib/saboteur/baseline/acceptance.rb
65
52
  - lib/saboteur/baseline/command.rb
66
53
  - lib/saboteur/baseline/comparison.rb
@@ -126,6 +113,7 @@ files:
126
113
  - lib/saboteur/engine/unscoped_suite_warning.rb
127
114
  - lib/saboteur/engine/verdict_check.rb
128
115
  - lib/saboteur/execution/external_runner.rb
116
+ - lib/saboteur/execution/fiddle_loader.rb
129
117
  - lib/saboteur/execution/fork_readiness.rb
130
118
  - lib/saboteur/execution/marker_sweep.rb
131
119
  - lib/saboteur/execution/peak_memory.rb
@@ -138,6 +126,7 @@ files:
138
126
  - lib/saboteur/execution/seatbelt.rb
139
127
  - lib/saboteur/execution/stale_captures.rb
140
128
  - lib/saboteur/execution/supervisor.rb
129
+ - lib/saboteur/execution/test_command_shape.rb
141
130
  - lib/saboteur/execution/worker_pool.rb
142
131
  - lib/saboteur/execution/wrapper.rb
143
132
  - lib/saboteur/frameworks/database_hosts.rb
@@ -153,6 +142,7 @@ files:
153
142
  - lib/saboteur/integration/focus_guard.rb
154
143
  - lib/saboteur/integration/rspec.rb
155
144
  - lib/saboteur/mutation_source/argument_removal.rb
145
+ - lib/saboteur/mutation_source/argument_swap.rb
156
146
  - lib/saboteur/mutation_source/base.rb
157
147
  - lib/saboteur/mutation_source/builtin.rb
158
148
  - lib/saboteur/mutation_source/chain_removal.rb