saboteur 0.3.0 → 0.5.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. metadata +58 -2
@@ -0,0 +1,170 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # Explicit source-file -> spec-file mapping from config/saboteur_custom_map.rb:
6
+ #
7
+ # SABOTEUR_CUSTOM_MAP = {
8
+ # "app/services/tax_sync.rb" => ["spec/services/tax_sync_spec.rb"],
9
+ # "app/services/**/*.rb" => ["spec/services/**/*_spec.rb"],
10
+ # %r{\Aapp/jobs/(.*)\.rb\z} => ['spec/jobs/\1_spec.rb'],
11
+ # "app/generated/**/*.rb" => [], # intentionally untested
12
+ # }.freeze
13
+ #
14
+ # The file loads before the app boots, so it must be plain Ruby.
15
+ class CustomMap
16
+ CONSTANT = :SABOTEUR_CUSTOM_MAP
17
+
18
+ Entry = Data.define(:key, :specs)
19
+
20
+ attr_reader :entries, :root
21
+
22
+ def self.load(path, root: Dir.pwd)
23
+ return new([], root: root) unless path && File.file?(path)
24
+
25
+ Object.send(:remove_const, CONSTANT) if Object.const_defined?(CONSTANT)
26
+ Kernel.load(path)
27
+ unless Object.const_defined?(CONSTANT)
28
+ raise ConfigError, "#{path}: expected it to define #{CONSTANT} = { ... }.freeze"
29
+ end
30
+
31
+ data = Object.const_get(CONSTANT)
32
+ raise ConfigError, "#{path}: #{CONSTANT} must be a Hash, got #{data.class}" unless data.is_a?(Hash)
33
+
34
+ new(data.map { |key, specs| validate_entry(path, root, key, specs) }, root: root)
35
+ rescue SyntaxError, LoadError => e
36
+ raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
37
+ rescue NameError => e
38
+ raise ConfigError, name_error_message(path, e)
39
+ ensure
40
+ Object.send(:remove_const, CONSTANT) if Object.const_defined?(CONSTANT)
41
+ end
42
+
43
+ def self.name_error_message(path, error)
44
+ line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
45
+ "#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
46
+ "- this file loads before the app boots; use plain Ruby or ENV"
47
+ end
48
+
49
+ def self.validate_entry(path, root, key, specs)
50
+ unless key.is_a?(String) || key.is_a?(Regexp)
51
+ raise ConfigError, "#{path}: key #{key.inspect} must be a String path, glob, or Regexp"
52
+ end
53
+ unless specs.is_a?(Array)
54
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{specs.inspect}; wrap spec paths in an array"
55
+ end
56
+
57
+ specs.each { |spec| validate_spec(path, root, key, spec) }
58
+ Entry.new(key: key, specs: specs)
59
+ end
60
+
61
+ def self.validate_spec(path, root, key, spec)
62
+ unless spec.is_a?(String)
63
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; spec paths must be Strings"
64
+ end
65
+ return if key.is_a?(Regexp) && spec.match?(/\\\d/) # backreference filled in at match time
66
+
67
+ expanded = File.expand_path(spec, root)
68
+ unless expanded.start_with?("#{root}/")
69
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which is outside the project root"
70
+ end
71
+ return if Dir.glob(expanded).any?
72
+
73
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which matches no file"
74
+ end
75
+
76
+ private_class_method :validate_entry, :validate_spec
77
+
78
+ def initialize(entries, root: Dir.pwd)
79
+ @entries = entries
80
+ @root = root
81
+ end
82
+
83
+ def empty?
84
+ @entries.empty?
85
+ end
86
+
87
+ # Spec paths for a source file, nil when no entry matches, :excluded when
88
+ # the winning entry maps to an empty array (intentionally untested).
89
+ def specs_for(file)
90
+ exact = @entries.find { |entry| entry.key == file }
91
+ return resolve(exact, nil) if exact
92
+
93
+ glob = glob_entry(file)
94
+ return resolve(glob, nil) if glob
95
+
96
+ regexp_specs(file)
97
+ end
98
+
99
+ # String keys (exact or glob) that match nothing under the root.
100
+ def stale_keys
101
+ @entries.select { |entry| entry.key.is_a?(String) }
102
+ .reject { |entry| Dir.glob(File.join(@root, entry.key)).any? }
103
+ .map(&:key)
104
+ end
105
+
106
+ # Literal String keys written twice (Ruby silently keeps the last).
107
+ # Detection is line-based and only sees double-quoted one-line keys;
108
+ # single-quoted, multi-line or %q keys that repeat go unreported.
109
+ def duplicate_keys(path)
110
+ seen = Hash.new(0)
111
+ File.readlines(path).each do |line|
112
+ seen[::Regexp.last_match(1)] += 1 if line =~ /^\s*"(.+?)"\s*=>/
113
+ end
114
+ seen.select { |_, count| count > 1 }.keys
115
+ end
116
+
117
+ def self.glob?(pattern, file)
118
+ File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
119
+ end
120
+
121
+ private
122
+
123
+ def glob_entry(file)
124
+ globs = @entries.select { |entry| entry.key.is_a?(String) && self.class.glob?(entry.key, file) }
125
+ return nil if globs.empty?
126
+
127
+ best = globs.group_by { |entry| specificity(entry.key) }.max_by { |score, _| score }.last
128
+ if best.size > 1
129
+ raise ConfigError,
130
+ "custom map keys #{best.map { |e| e.key.inspect }.join(' and ')} both match #{file} " \
131
+ "with equal specificity; make one more specific"
132
+ end
133
+ best.first
134
+ end
135
+
136
+ def regexp_specs(file)
137
+ matches = @entries.filter_map do |entry|
138
+ next unless entry.key.is_a?(Regexp)
139
+
140
+ match = entry.key.match(file)
141
+ [entry, match] if match
142
+ end
143
+ return nil if matches.empty?
144
+ # Exclusion is unanimous: a file is intentionally untested only when
145
+ # every matching regexp entry maps to an empty array. One mapped entry
146
+ # is enough to keep the file tested; unmapped entries add nothing.
147
+ return :excluded if matches.all? { |entry, _| entry.specs.empty? }
148
+
149
+ matches.reject { |entry, _| entry.specs.empty? }
150
+ .flat_map { |entry, match| resolve(entry, match) }
151
+ end
152
+
153
+ def specificity(key)
154
+ key.count("^*?[]{}\\")
155
+ end
156
+
157
+ def resolve(entry, match)
158
+ return :excluded if entry.specs.empty?
159
+
160
+ specs = entry.specs
161
+ if match
162
+ # A backreference to a group that did not participate expands to "";
163
+ # that is never a real spec path, so drop it here rather than letting
164
+ # a junk entry reach the run.
165
+ specs = specs.map { |spec| spec.gsub(/\\(\d+)/) { match[::Regexp.last_match(1).to_i].to_s } }.reject(&:empty?)
166
+ end
167
+ specs
168
+ end
169
+ end
170
+ end
@@ -5,11 +5,13 @@ require "coverage"
5
5
  require_relative "baseline_health"
6
6
 
7
7
  module Saboteur
8
- # Reports the current environment and runs baseline checks, never mutations.
8
+ # Reports the current environment, never mutations. The two unchanged suite
9
+ # passes are slow on a large suite, so they run only when asked for.
9
10
  class Doctor
10
- def initialize(config, out: $stdout)
11
+ def initialize(config, out: $stdout, suite: false)
11
12
  @config = config
12
13
  @out = out
14
+ @suite = suite
13
15
  end
14
16
 
15
17
  def call
@@ -17,20 +19,32 @@ module Saboteur
17
19
  check_runtime
18
20
  check_coverage
19
21
  check_boot_files
22
+ check_custom_map
23
+ check_ignore_subjects
20
24
  hooks = Hooks.new
21
25
  integration = boot_target(hooks)
22
26
  @out.puts "Integration: #{@config['integration']} (loaded)"
23
27
  @out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
24
28
  @out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
25
29
  check_coverage
26
- tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
27
- @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
28
- @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
30
+ check_suite(integration, hooks)
29
31
  true
30
32
  end
31
33
 
32
34
  private
33
35
 
36
+ def check_suite(integration, hooks)
37
+ unless @suite
38
+ @out.puts "Baseline: not run. Pass --suite to run the unchanged suite twice " \
39
+ "(add --test PATH to limit it to some specs)."
40
+ return
41
+ end
42
+
43
+ tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
44
+ @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
45
+ @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
46
+ end
47
+
34
48
  def boot_target(hooks)
35
49
  Engine::Boot.new(@config, hooks).load_target
36
50
  rescue LoadError, SyntaxError, SystemExit, StandardError => e
@@ -51,17 +65,51 @@ module Saboteur
51
65
  def check_coverage
52
66
  state = Coverage.state
53
67
  @out.puts "Coverage: #{state}"
54
- return if Coverage.supported?(:lines) && state == :suspended
68
+ return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
69
+
70
+ raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
71
+ "executable, disable SimpleCov and remove other Coverage.start calls"
72
+ end
73
+
74
+ # Match against all included source definitions, not the current diff or
75
+ # selected tests. A limited run must not make a valid ignore look stale.
76
+ def check_ignore_subjects
77
+ ignores = IgnoreList.load(@config.path("ignore_file"))
78
+ return if ignores.entries.empty?
79
+
80
+ finder = SubjectFinder.new
81
+ parsed = @config.included_files.map { |file| finder.call(file) }
82
+ if parsed.any? { |result| result.errors.any? }
83
+ @out.puts "Warning: ignore subject check incomplete: an included file does not parse"
84
+ return
85
+ end
86
+ subjects = parsed.flat_map(&:subjects).to_set(&:id)
87
+ ignores.entries.map(&:subject).uniq.reject { |subject| subjects.include?(subject) }.each do |subject|
88
+ @out.puts "Warning: ignore subject #{subject} matches no discovered subject in included files; " \
89
+ "check include/exclude settings and the exact Class#method or Class.method form"
90
+ end
91
+ end
92
+
93
+ def check_custom_map
94
+ path = @config.path("custom_map")
95
+ map = CustomMap.load(path, root: @config.root)
96
+ return @out.puts("Custom map: none (optional)") if map.empty?
97
+
98
+ duplicates = map.duplicate_keys(path)
99
+ if duplicates.any?
100
+ @out.puts "Warning: custom map keys written twice, Ruby keeps the last: #{duplicates.join(', ')}"
101
+ end
102
+ stale = map.stale_keys
103
+ raise ConfigError, "#{path}: stale keys matching no file: #{stale.join(', ')}" if stale.any?
55
104
 
56
- raise Error, "Coverage must be suspended with line support before boot; use the saboteur executable, " \
57
- "disable SimpleCov and remove other Coverage.start calls"
105
+ @out.puts "Custom map: #{map.entries.size} entries (loaded)"
58
106
  end
59
107
 
60
108
  def check_boot_files
61
109
  %w[require hooks].each do |key|
62
110
  @config[key].each do |file|
63
111
  path = File.expand_path(file, @config.root)
64
- raise ConfigError, "#{key} file missing: #{file}; fix .saboteur.yml" unless File.file?(path)
112
+ raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb" unless File.file?(path)
65
113
 
66
114
  @out.puts "#{key}: #{file} (found)"
67
115
  end
@@ -10,6 +10,10 @@ module Saboteur
10
10
  # or mutation source is used. This catches observed instability, not all
11
11
  # possible flaky behaviour.
12
12
  class BaselineGuard
13
+ EXCERPT_LINES = 10
14
+ EXCERPT_WIDTH = 200
15
+ EXCERPT_SCAN_LINES = 5000
16
+
13
17
  def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
14
18
  @config = config
15
19
  @integration = integration
@@ -45,15 +49,32 @@ module Saboteur
45
49
 
46
50
  def check(number)
47
51
  Execution::ForkReadiness.prepare_heap
48
- result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, number) }
52
+ heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
53
+ result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
54
+ record(writer, number)
55
+ end
49
56
  refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
57
+ # Before the payload checks: a child killed by a signal (the OOM
58
+ # killer, say) writes nothing, and the signal is the explanation. A
59
+ # child the supervisor itself killed after it had reported is not a
60
+ # failure: its payload is checked below.
61
+ refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
50
62
  refuse(number, "the child did not report a result") if result.payload.empty?
51
- refuse(number, "the child exited unsuccessfully") if
52
- result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
53
63
  refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
54
64
  parse_result(number, result.payload)
55
65
  end
56
66
 
67
+ # A signaled child (the OOM killer, say) can leave an empty log,
68
+ # which reads like a suite failure; name the signal. The OOM hint is
69
+ # printed only when the supervisor saw the process tree's memory grow.
70
+ def unsuccessful_exit(status, memory_pressure)
71
+ return "the child exited with status #{status.exitstatus}" unless status.signaled?
72
+
73
+ signal = "SIG#{Signal.signame(status.termsig)}"
74
+ hint = signal == "SIGKILL" && memory_pressure ? " - possible OOM, check memory" : ""
75
+ "the child was killed by #{signal}#{hint}"
76
+ end
77
+
57
78
  def parse_result(number, payload)
58
79
  data = JSON.parse(payload)
59
80
  refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
@@ -91,8 +112,26 @@ module Saboteur
91
112
 
92
113
  def refuse(number, reason)
93
114
  raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
94
- "See the log at #{log_path(number)}. Fix the suite or its setup, then run saboteur again " \
95
- "with the same seed. No mutants were started."
115
+ "#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
116
+ "setup, then run saboteur again with the same seed. No mutants were started."
117
+ end
118
+
119
+ # The first lines of the failure, inline, so the cause is readable
120
+ # without opening the log: RSpec's "Failures:" section when the log has
121
+ # one, otherwise the last lines of the log.
122
+ def failure_excerpt(number)
123
+ lines = log_lines(number)
124
+ return "" if lines.empty?
125
+
126
+ start = lines.index { |line| line.start_with?("Failures:") }
127
+ shown = start ? lines[start, EXCERPT_LINES] : lines.last(EXCERPT_LINES)
128
+ "The log says:\n#{shown.map { |line| " #{line.slice(0, EXCERPT_WIDTH)}" }.join("\n")}\n"
129
+ end
130
+
131
+ def log_lines(number)
132
+ File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES).reject { |line| line.strip.empty? }
133
+ rescue SystemCallError
134
+ []
96
135
  end
97
136
  end
98
137
  end
@@ -62,6 +62,7 @@ module Saboteur
62
62
  @rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
63
63
  rails&.before_boot
64
64
  @hooks.run(:before_boot)
65
+ add_spec_to_load_path if @config["rails"]
65
66
  @config["require"].each { |file| require File.expand_path(file, @root) }
66
67
  integration = Integration.fetch(@config["integration"]).new(@config)
67
68
  integration.setup
@@ -72,6 +73,14 @@ module Saboteur
72
73
 
73
74
  private
74
75
 
76
+ # rspec puts spec/ on the load path, so a boot file under spec/ (a
77
+ # dummy app's rails_helper, say) can require its siblings. Match
78
+ # that, or the same require list fails to load.
79
+ def add_spec_to_load_path
80
+ path = File.join(@root, "spec")
81
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
82
+ end
83
+
75
84
  def load_hooks
76
85
  Saboteur.current_hooks = @hooks
77
86
  @config["hooks"].each { |file| load File.expand_path(file, @root) }
@@ -0,0 +1,95 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Trivial compiler equivalence: a mutant whose method compiles to the
7
+ # same instruction sequence as the original cannot change behaviour, so
8
+ # running it can only produce a false survivor. The comparison walks the
9
+ # compiled instruction tree and drops only position metadata (line
10
+ # numbers, line events, source spans, node ids, file paths). Operands,
11
+ # including string and numeric literals, are compared exactly (2 and 2.0 differ, and so do +0.0 and -0.0).
12
+ class BytecodeEquivalence
13
+ ISEQ_MAGIC = "YARVInstructionSequence/SimpleDataFormat"
14
+ # Operand types whose eql? is exact. Anything else (an unknown object,
15
+ # a type that could hold a Float) is never called equal, so its
16
+ # mutant still runs.
17
+ PLAIN = [Integer, String, Symbol, Rational, Regexp, NilClass, TrueClass, FalseClass].freeze
18
+ POSITION_EVENT = /\ARUBY_EVENT_/
19
+
20
+ # True when mutated_text compiles to the same instructions as the
21
+ # original subject source under the same compile options. Anything the
22
+ # compiler cannot judge counts as not equivalent, so the mutant still
23
+ # runs. frozen_string_literal: whether the file carries that magic
24
+ # comment, which changes how string literals compile.
25
+ def equivalent?(original_text, mutated_text, frozen_string_literal: false)
26
+ original = fingerprint_of_original(original_text, frozen_string_literal)
27
+ return false unless original
28
+
29
+ exact?(original, fingerprint(mutated_text, frozen_string_literal))
30
+ end
31
+
32
+ private
33
+
34
+ # Mutants of one method arrive together, so the last original is enough.
35
+ def fingerprint_of_original(text, frozen)
36
+ key = [text, frozen]
37
+ return @original_fingerprint if key == @original_key
38
+
39
+ @original_key = key
40
+ @original_fingerprint = fingerprint(text, frozen)
41
+ end
42
+
43
+ def fingerprint(text, frozen)
44
+ options = { frozen_string_literal: frozen }
45
+ strip_positions(RubyVM::InstructionSequence.compile(text, nil, nil, 1, options).to_a)
46
+ rescue SyntaxError, StandardError
47
+ nil
48
+ end
49
+
50
+ # Structural equality that never coerces: an Integer is not a Float,
51
+ # and +0.0 is not -0.0 (1.0 / +0.0 and 1.0 / -0.0 are different
52
+ # infinities), which eql? alone would not tell apart.
53
+ def exact?(left, right)
54
+ case left
55
+ when Array then right.is_a?(Array) && left.size == right.size && left.zip(right).all? { |l, r| exact?(l, r) }
56
+ when Hash, Complex, Range then right.instance_of?(left.class) && exact?(parts(left), parts(right))
57
+ when Float then right.is_a?(Float) && [left].pack("G") == [right].pack("G")
58
+ when *PLAIN then left.eql?(right)
59
+ else false
60
+ end
61
+ end
62
+
63
+ # The values a composite operand is made of, compared one by one.
64
+ def parts(value)
65
+ case value
66
+ when Hash then value.to_a
67
+ when Complex then value.rectangular
68
+ else [value.begin, value.end, value.exclude_end?]
69
+ end
70
+ end
71
+
72
+ def strip_positions(node)
73
+ return node unless node.is_a?(Array)
74
+ return node.map { |child| strip_positions(child) } unless node.first == ISEQ_MAGIC
75
+
76
+ _magic, major, minor, format, _misc, name, _path, _real_path, _first_line, type, locals, params, catches,
77
+ body = node
78
+ [major, minor, format, name, type, locals, strip_positions(params), strip_positions(catches),
79
+ strip_body(body)]
80
+ end
81
+
82
+ def strip_body(body)
83
+ body.reject { |entry| position?(entry) }.map { |entry| strip_positions(entry) }
84
+ end
85
+
86
+ # Line numbers and line/call/return events depend only on layout.
87
+ def position?(entry)
88
+ case entry
89
+ when Integer, POSITION_EVENT then true
90
+ else false
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ module Saboteur
7
+ class Engine
8
+ # Watches the working tree while mutants run (issue #102). A mutant that
9
+ # slips past the hazard guard and deletes tracked files must abort the
10
+ # run, not be reported as a survivor. The snapshot is the tracked files
11
+ # (git ls-files) when the tree is a repository, else every file under the
12
+ # root; the tool's own cache directory is excluded in both cases, and the
13
+ # presence of .git is watched separately.
14
+ class Canary
15
+ GIT_DIR = ".git"
16
+
17
+ def initialize(root, cache_dir: ".saboteur")
18
+ @root = root
19
+ @cache_dir = cache_dir.to_s
20
+ @snapshot = inventory
21
+ end
22
+
23
+ # After a subject's mutants: any file present at snapshot time and gone
24
+ # now means a mutant deleted it. digest names the most recent mutant,
25
+ # the likely cause.
26
+ def check!(subject: nil, digest: nil)
27
+ missing = @snapshot - inventory
28
+ return if missing.empty?
29
+
30
+ of = " of #{subject.id}" if subject
31
+ culprit = digest ? " The most recent mutant, #{digest}#{of}, is the likely cause." : ""
32
+ raise Saboteur::CanaryTripped,
33
+ "canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
34
+ "(first: #{missing.first}).#{culprit} The canary watches tracked files and .git presence " \
35
+ "and only detects the loss after the fact; it cannot restore anything. Recover from your " \
36
+ "own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
37
+ "unless you have another copy."
38
+ end
39
+
40
+ private
41
+
42
+ def inventory
43
+ files = git? ? git_tracked_files : walk_files
44
+ files << GIT_DIR if File.exist?(File.join(@root, GIT_DIR))
45
+ files.uniq
46
+ end
47
+
48
+ def git?
49
+ File.directory?(File.join(@root, GIT_DIR)) && system("git", "--version", %i[out err] => File::NULL)
50
+ end
51
+
52
+ def git_tracked_files
53
+ out, status = Open3.capture2("git", "ls-files", "-z", chdir: @root)
54
+ return walk_files unless status.success?
55
+
56
+ # ls-files reads the index, which still names a deleted file; keep
57
+ # only paths present in the worktree so a deletion shows up.
58
+ out.split("\0").reject { |rel| cache_path?(rel) }
59
+ .select { |rel| File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel)) }
60
+ end
61
+
62
+ def walk_files
63
+ Dir.glob(File.join(@root, "**", "*"), File::FNM_DOTMATCH)
64
+ .select { |path| File.file?(path) }
65
+ .map { |path| path.delete_prefix("#{@root}/") }
66
+ .reject { |rel| rel.start_with?("#{GIT_DIR}/") || cache_path?(rel) }
67
+ end
68
+
69
+ def cache_path?(rel)
70
+ rel.start_with?("#{@cache_dir}/")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -6,7 +6,7 @@ module Saboteur
6
6
  # Selects a bounded list for each covered subject before workers fork.
7
7
  # Subject skips are not mutants and do not enter the mutation score.
8
8
  class CandidateSelection
9
- attr_reader :skipped_subjects
9
+ attr_reader :skipped_subjects, :selected
10
10
 
11
11
  def initialize(candidates:, map:, limit:, diagnostics:)
12
12
  @candidates = candidates
@@ -14,21 +14,34 @@ module Saboteur
14
14
  @limit = limit
15
15
  @diagnostics = diagnostics
16
16
  @skipped_subjects = {}
17
+ @selected = {}
17
18
  end
18
19
 
19
20
  def for(subject, scope: nil)
20
- if @map.tests_for(subject).empty?
21
- @skipped_subjects[subject] = "no coverage"
22
- return []
21
+ reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
22
+ if reason
23
+ @skipped_subjects[subject] = reason
24
+ return @selected[subject] = []
23
25
  end
24
26
 
25
27
  list = @candidates.for(subject)
26
28
  list = scope.restrict(subject, list) if scope
27
29
  omitted = list.size - @limit
28
30
  if omitted.positive?
31
+ @diagnostics.record_omission(subject, count: omitted, limit: @limit)
29
32
  @diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
30
33
  end
31
- list.first(@limit)
34
+ @selected[subject] = list.first(@limit)
35
+ end
36
+
37
+ private
38
+
39
+ # Load-only code is not "no coverage": it ran, just never inside a
40
+ # test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
41
+ def skip_reason_for(subject)
42
+ return "no coverage" unless @map.ran_at_load?(subject.file, subject.coverage_lines)
43
+
44
+ Selection::CoverageMap::RAN_AT_LOAD
32
45
  end
33
46
  end
34
47
  end