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
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "prism"
5
+ require_relative "../inline_suppression"
5
6
 
6
7
  module Saboteur
7
8
  class Engine
@@ -12,7 +13,7 @@ module Saboteur
12
13
  def self.build_sources(config)
13
14
  root = File.realpath(config.root)
14
15
  config["sources"].map do |entry|
15
- next MutationSource::Builtin.new(arid: config["arid"]) if entry == "builtin"
16
+ next MutationSource::Builtin.new(arid: config["arid"], operators: config["operators"]) if entry == "builtin"
16
17
 
17
18
  file, const = entry.split(":", 2)
18
19
  raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
@@ -22,37 +23,88 @@ module Saboteur
22
23
  end
23
24
  end
24
25
 
25
- def initialize(sources, diagnostics)
26
+ def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new)
26
27
  @sources = sources
27
28
  @diagnostics = diagnostics
29
+ @hazard_guard = hazard_guard
30
+ @bytecode = bytecode
28
31
  end
29
32
 
30
33
  def for(subject)
31
- return [] unless parses?(subject.source)
34
+ unless parses?(subject.source)
35
+ @diagnostics.record_generation(subject, source: "subject", reason: "invalid_subject_syntax")
36
+ return []
37
+ end
32
38
 
33
39
  seen = Set[subject.source]
34
- list = @sources.flat_map { |source| source.mutations_for(subject) }.filter_map do |mutation|
40
+ list = @sources.flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
35
41
  candidate(subject, mutation, seen)
36
42
  end
43
+ list = suppress_inline(list, subject)
44
+ list = suppress_hazards(list, subject)
37
45
  list.sort_by.with_index { |(mutation, _), index| [mutation.coarse ? 0 : 1, index] }
38
46
  end
39
47
 
40
48
  private
41
49
 
50
+ def suppress_inline(list, subject)
51
+ directives = InlineSuppression.new(subject.file, subject.file_source)
52
+ list.reject do |mutation, _text|
53
+ directive = directives.reason(subject, mutation)
54
+ @diagnostics.record_inline_suppression(subject, mutation, directive) if directive
55
+ directive
56
+ end
57
+ end
58
+
59
+ # The hazard guard (issue #102) refuses mutants that would leave a
60
+ # destructive call running with altered inputs; each refusal is counted.
61
+ def suppress_hazards(list, subject)
62
+ return list unless @hazard_guard
63
+
64
+ kept, suppressed = @hazard_guard.partition(list, subject)
65
+ suppressed.each { |(mutation, _)| @diagnostics.record_hazard_suppression(subject, mutation) }
66
+ kept
67
+ end
68
+
69
+ def proposals(source, subject)
70
+ proposals = source.mutations_for(subject)
71
+ if proposals.empty?
72
+ @diagnostics.record_generation(subject, source: source_name(source), reason: "empty_generation")
73
+ end
74
+ proposals
75
+ end
76
+
77
+ def source_name(source)
78
+ name = source.name if source.respond_to?(:name)
79
+ name || "anonymous"
80
+ end
81
+
82
+ def record_drop(subject, mutation, reason)
83
+ @diagnostics.record_generation(subject, source: mutation.source, operator: mutation.operator, reason: reason)
84
+ nil
85
+ end
86
+
42
87
  def candidate(subject, mutation, seen)
43
88
  text = subject.apply(mutation.edits)
44
- return if seen.include?(text)
89
+ return record_drop(subject, mutation, "unchanged") if text == subject.source
90
+ return record_drop(subject, mutation, "duplicate") if seen.include?(text)
45
91
 
46
92
  seen << text
47
93
  unless parses?(text)
48
94
  @diagnostics.count_invalid_mutation
49
- return
95
+ return record_drop(subject, mutation, "invalid_syntax")
50
96
  end
51
97
 
98
+ return record_drop(subject, mutation, "equivalent_bytecode") if equivalent?(subject, text)
99
+
52
100
  [mutation, text]
53
101
  rescue ArgumentError => e
54
102
  @diagnostics.warn("#{subject.id}: dropped #{mutation.source} mutation: #{e.message}")
55
- nil
103
+ record_drop(subject, mutation, "invalid_edit")
104
+ end
105
+
106
+ def equivalent?(subject, text)
107
+ @bytecode.equivalent?(subject.source, text, frozen_string_literal: subject.frozen_literals)
56
108
  end
57
109
 
58
110
  def parses?(text)
@@ -15,22 +15,25 @@ module Saboteur
15
15
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
16
16
  LOG = "coverage-build.log"
17
17
 
18
- def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
18
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {})
19
19
  @config = config
20
20
  @integration = integration
21
21
  @hooks = hooks
22
22
  @supervisor = supervisor
23
23
  @diagnostics = diagnostics
24
+ @load_lines = load_lines
24
25
  @root = File.realpath(config.root)
25
26
  end
26
27
 
27
28
  def fetch(files)
28
29
  path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
29
30
  cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
30
- return cached if cached
31
+ return refuse_empty_map(cached, files) if cached
31
32
 
32
- map = build(files)
33
+ map = refuse_empty_map(build(files), files)
33
34
  map.save(path)
35
+ File.write(File.join(@config.path("cache_dir"), "preview.json"),
36
+ JSON.generate(path: path, fingerprint: RunFingerprint.new(config: @config).snapshot.digest))
34
37
  map
35
38
  end
36
39
 
@@ -52,18 +55,62 @@ module Saboteur
52
55
  "Run through the saboteur executable and disable SimpleCov for the run."
53
56
  end
54
57
 
55
- @diagnostics.inform("building coverage map (runs the whole suite once)")
58
+ scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
59
+ @diagnostics.inform("building coverage map (runs #{scope} once)")
56
60
  # The suite child forked next inherits this heap; pack it first, to
57
61
  # reduce later copying of shared pages.
58
62
  Execution::ForkReadiness.prepare_heap
59
- result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
63
+ heartbeat = @diagnostics.progress_heartbeat("building coverage map").watch(log_path)
64
+ result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
65
+ record(writer, files)
66
+ end
67
+ map_from(result)
68
+ end
69
+
70
+ def map_from(result)
60
71
  raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
61
72
  raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
62
73
 
63
74
  data = JSON.parse(result.payload)
64
75
  raise Error, "coverage map build failed: #{data['error']}" if data["error"]
65
76
 
66
- Selection::CoverageMap.from_json(result.payload)
77
+ with_parent_load_lines(Selection::CoverageMap.from_json(result.payload))
78
+ end
79
+
80
+ # The parent's load lines merge into the freshly built map: the forked
81
+ # build child cannot inherit the parent's Coverage counters.
82
+ def with_parent_load_lines(map)
83
+ @load_lines.empty? ? map : map.with_load_lines(@load_lines)
84
+ end
85
+
86
+ # A whole-suite build that records nothing - no line hit and no method
87
+ # call - in any included file fails the run (exit 3): usually no test
88
+ # in the suite exercises these files, or the setup hid the app from
89
+ # Coverage (Bootsnap's compile cache is the default Rails case). An
90
+ # all-endless-def file records method calls only and passes. Either way
91
+ # a run on a truly empty map would test nothing, and exit 0 would lie.
92
+ # With explicit tests (--test) an empty map is a legitimate outcome
93
+ # instead: the user narrowed the suite to specs that may not touch
94
+ # these sources, so the map passes with a warning. Applies to a cached
95
+ # map too, so a map cached before this check cannot keep a run green,
96
+ # and the guard runs before saving, so an empty map is never cached.
97
+ def refuse_empty_map(map, files)
98
+ return map if files.empty? || !map.empty?
99
+
100
+ if @config["tests"].empty?
101
+ raise Error, "Coverage recorded nothing in #{files.size} included files during a whole-suite " \
102
+ "build. No test in the suite may exercise these files, or the setup hid the app " \
103
+ "from Coverage#{bootsnap_note}. Run `saboteur doctor`."
104
+ end
105
+ @diagnostics.warn("Coverage recorded nothing in #{files.size} included files; " \
106
+ "the selected specs may not exercise them.")
107
+ map
108
+ end
109
+
110
+ # Bootsnap is named only when it is loaded: blaming a library the app
111
+ # does not use sends the reader to the wrong place.
112
+ def bootsnap_note
113
+ " (Bootsnap's compile cache is the default Rails case)" if defined?(::Bootsnap)
67
114
  end
68
115
 
69
116
  def died(status)
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Non-Ruby files a test run can read and that change its outcome: Rails
7
+ # fixtures, schema dumps and database/environment configuration, plus
8
+ # anything listed under `fingerprint_extra`. Ruby files are tracked
9
+ # elsewhere; these have no .rb extension, so the Ruby walk misses them.
10
+ # An absent pattern simply matches nothing, so plain gems are unaffected.
11
+ module DataInputs
12
+ GLOBS = %w[
13
+ spec/fixtures/**/*
14
+ spec/**/fixtures/**/*
15
+ test/fixtures/**/*
16
+ db/structure.sql
17
+ db/schema.rb
18
+ config/database.yml
19
+ config/database.yml.erb
20
+ config/*.yml
21
+ config/environments/*.yml
22
+ config/credentials/*.yml.enc
23
+ ].freeze
24
+
25
+ # True when the absolute path is one of the declared inputs, whether or
26
+ # not the file exists yet. A run must never write there.
27
+ def self.match?(root, path, extra: [])
28
+ return false unless path.start_with?("#{root}/")
29
+
30
+ relative = path.delete_prefix("#{root}/")
31
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
32
+ (GLOBS + extra).any? { |pattern| File.fnmatch?(pattern, relative, flags) }
33
+ end
34
+
35
+ # Sorted absolute paths of regular files (symlinks followed) under root.
36
+ def self.files(root, extra: [])
37
+ (GLOBS + extra).flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: root) }
38
+ .map { |relative| File.join(root, relative) }
39
+ .select { |path| File.file?(path) }.uniq.sort
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,19 +1,90 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "tempfile"
5
+
4
6
  module Saboteur
5
7
  class Engine
6
8
  # What a run has to say besides results: warnings for the report, the
7
9
  # count of mutations dropped for not parsing, and progress marks.
8
10
  class Diagnostics
11
+ # See progress_heartbeat. Time-gated itself, so the supervisor can
12
+ # call tick on every poll. A phase whose child writes "N/M examples
13
+ # finished" lines to a log (the baseline and coverage children do) can
14
+ # be watched: the heartbeat then adds the count, the percentage and an
15
+ # estimate of the time left to its elapsed-time line.
16
+ class ProgressHeartbeat
17
+ COUNT = %r{(\d+)/([1-9]\d*) examples finished}
18
+ TAIL_BYTES = 4096
19
+
20
+ def initialize(diagnostics, label, interval: 30)
21
+ @diagnostics = diagnostics
22
+ @label = label
23
+ @interval = interval
24
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ @last_print = @started
26
+ end
27
+
28
+ # Reads progress from the end of this log on each print. Returns self.
29
+ def watch(log_path)
30
+ @log = log_path
31
+ self
32
+ end
33
+
34
+ def tick
35
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ return if now - @last_print < @interval
37
+
38
+ @last_print = now
39
+ elapsed = now - @started
40
+ @diagnostics.inform("#{@label}: #{elapsed.round} s elapsed#{progress_note(elapsed)}")
41
+ end
42
+
43
+ private
44
+
45
+ # ", 250/1000 examples (25 %), about 90 s left", or "" when the log has
46
+ # no count yet. The estimate assumes examples take equally long, so it
47
+ # is rough: slow examples late in the order make it too low.
48
+ def progress_note(elapsed)
49
+ done, total = examples_finished
50
+ return "" if done.nil?
51
+
52
+ note = ", #{done}/#{total} examples (#{(done * 100.0 / total).floor} %)"
53
+ return note if done.zero? || done >= total
54
+
55
+ "#{note}, about #{(elapsed * (total - done) / done).ceil} s left"
56
+ end
57
+
58
+ def examples_finished
59
+ return unless @log
60
+
61
+ tail = File.read(@log, TAIL_BYTES, [File.size(@log) - TAIL_BYTES, 0].max).to_s.scrub
62
+ tail.scan(COUNT).last&.map(&:to_i)
63
+ rescue SystemCallError
64
+ nil
65
+ end
66
+ end
67
+
9
68
  MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
69
+ LEGEND = ". killed S survived T timeout L resource limit E error"
70
+ LINE_WIDTH = 50
10
71
 
11
- attr_reader :warnings, :invalid_mutations
72
+ GenerationEvent = Data.define(:file, :subject, :source, :operator, :reason)
73
+ Omission = Data.define(:subject, :count, :limit)
74
+ DiscoveryFailure = Data.define(:file, :reason)
75
+
76
+ attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
77
+ :hazard_suppressions, :inline_suppressions
12
78
 
13
79
  def initialize(err)
14
80
  @err = err
15
81
  @warnings = []
16
82
  @invalid_mutations = 0
83
+ @generation_events = []
84
+ @omissions = []
85
+ @discovery_failures = []
86
+ @hazard_suppressions = 0
87
+ @inline_suppressions = []
17
88
  end
18
89
 
19
90
  def warn(message)
@@ -24,14 +95,117 @@ module Saboteur
24
95
  @invalid_mutations += 1
25
96
  end
26
97
 
98
+ def record_generation(subject, source:, reason:, operator: nil)
99
+ values = [subject.file, subject.id, source, operator, reason].map do |value|
100
+ value.nil? ? nil : value.to_s.dup.freeze
101
+ end
102
+ @generation_events << GenerationEvent.new(*values)
103
+ end
104
+
105
+ def record_discovery_failure(file, reason:)
106
+ @discovery_failures << DiscoveryFailure.new(file: file.to_s.dup.freeze, reason: reason.to_s.dup.freeze)
107
+ end
108
+
109
+ # A mutant the hazard guard refused to generate (issue #102): counted
110
+ # for the summary and recorded like any other generation event.
111
+ def record_hazard_suppression(subject, mutation)
112
+ @hazard_suppressions += 1
113
+ record_generation(subject, source: "hazard_guard", operator: mutation.operator, reason: "hazardous")
114
+ end
115
+
116
+ def record_inline_suppression(subject, mutation, directive)
117
+ @inline_suppressions << {
118
+ file: subject.file, line: directive.line, subject: subject.id,
119
+ operator: mutation.operator, reason: directive.reason,
120
+ digest: mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
121
+ }
122
+ end
123
+
124
+ def record_omission(subject, count:, limit:)
125
+ @omissions << Omission.new(subject: subject.id.dup.freeze, count: count, limit: limit)
126
+ end
127
+
27
128
  def inform(message)
28
129
  @err.puts("saboteur: #{message}")
29
130
  end
30
131
 
132
+ # A heartbeat for a long phase: the supervisor's wait loop calls tick
133
+ # in the parent after the fork, so no extra thread is ever alive at
134
+ # fork time. Prints "<label>: N s elapsed" about every interval
135
+ # seconds, so a long phase does not look stuck.
136
+ def progress_heartbeat(label, interval: 30)
137
+ ProgressHeartbeat.new(self, label, interval: interval)
138
+ end
139
+
140
+ # Call before the workers fork. total is the number of mutants the run
141
+ # may execute; mutants that are skipped or reused never report progress,
142
+ # so the counter can end below total and the ETA errs on the long side.
143
+ # The count lives in one file the workers inherit, so every worker adds
144
+ # to the same number.
145
+ def start_progress(total)
146
+ @progress_total = total
147
+ @progress_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
148
+ @progress_file = Tempfile.new("saboteur-progress")
149
+ @progress_file.binmode
150
+ @progress_file.sync = true
151
+ @err.puts("saboteur: progress #{LEGEND}") unless progress_on_terminal?
152
+ end
153
+
31
154
  def progress(result)
32
- @err.print(MARKS.fetch(result.status, "?"))
155
+ mark = MARKS.fetch(result.status, "?")
156
+ return plain_progress(mark) unless @progress_file
157
+
158
+ done = record_progress(mark)
159
+ progress_on_terminal? ? terminal_progress(done) : ci_progress(mark, done)
160
+ @err.flush
161
+ end
162
+
163
+ def finish_progress
164
+ @err.puts if @progress_file && (progress_on_terminal? || @progress_file.size % LINE_WIDTH != 0)
165
+ @progress_file&.close!
166
+ @progress_file = nil
167
+ end
168
+
169
+ def progress_summary(done)
170
+ return "#{done}/#{@progress_total}" unless @progress_total.to_i.positive?
171
+
172
+ percent = (done * 100.0 / @progress_total).floor
173
+ "#{done}/#{@progress_total} (#{percent}%)#{eta_text(done)}"
174
+ end
175
+
176
+ private
177
+
178
+ def plain_progress(mark)
179
+ @err.print(mark)
33
180
  @err.flush
34
181
  end
182
+
183
+ def record_progress(mark)
184
+ @progress_file.write(mark)
185
+ @progress_file.size
186
+ end
187
+
188
+ def progress_on_terminal?
189
+ @err.respond_to?(:tty?) && @err.tty?
190
+ end
191
+
192
+ def terminal_progress(done)
193
+ @err.print("\r\e[Ksaboteur: #{progress_summary(done)}")
194
+ end
195
+
196
+ def ci_progress(mark, done)
197
+ @err.print(mark)
198
+ @err.print(" #{progress_summary(done)}\n") if (done % LINE_WIDTH).zero?
199
+ end
200
+
201
+ def eta_text(done)
202
+ remaining = @progress_total - done
203
+ return "" if remaining <= 0 || done.zero?
204
+
205
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @progress_started
206
+ seconds = (elapsed / done * remaining).round
207
+ " eta #{seconds / 60}m#{format('%02d', seconds % 60)}s"
208
+ end
35
209
  end
36
210
  end
37
211
  end
@@ -20,6 +20,24 @@ module Saboteur
20
20
  end
21
21
  end
22
22
 
23
+ # The changed lines a mutant touches, sorted. A statement that spans
24
+ # several lines is mutated as one piece and reported at its first line,
25
+ # which can sit above the line that changed; this names the changed line.
26
+ def changed_lines_for(subject, mutation)
27
+ changed = @changed_lines.fetch(subject.file, Set.new)
28
+ mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
29
+ end
30
+
31
+ # Distance to the nearest changed line in this file. Unknown outside
32
+ # the diff is nil, not a made-up nearby location.
33
+ def distance_for(subject, mutation)
34
+ changed = @changed_lines.fetch(subject.file, Set.new)
35
+ mutation.edits.map do |edit|
36
+ first, last = edited_lines(subject, edit).minmax
37
+ changed.map { |line| (line - line.clamp(first, last)).abs }.min
38
+ end.min
39
+ end
40
+
23
41
  private
24
42
 
25
43
  def edited_lines(subject, edit)
@@ -8,19 +8,37 @@ module Saboteur
8
8
  # plain data; the parent rebuilds them against its own copy of the same
9
9
  # candidate list, so results look the same either way.
10
10
  class Distribution
11
- def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil)
11
+ # After run: the subjects a stop_after callable left unrun (always empty
12
+ # without one).
13
+ attr_reader :unreached
14
+
15
+ # stop_after: nil, or a callable that gets one subject's results and
16
+ # returns true to stop before the next subject (--fail-fast). It only
17
+ # works on one process: parallel workers have already started the
18
+ # other subjects.
19
+ def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil, after_subject: nil,
20
+ stop_after: nil)
12
21
  @jobs = jobs
22
+ @stop_after = stop_after
23
+ @unreached = []
13
24
  @on_start = on_start
14
25
  @scheduler = scheduler
15
26
  @diagnostics = diagnostics
16
27
  @cache = cache
28
+ @after_subject = after_subject
17
29
  end
18
30
 
19
31
  # lists: each subject's [[mutation, text]] candidates, in subject order.
20
32
  def run(subjects, lists)
21
- return subjects.zip(lists).flat_map { |subject, list| @scheduler.call(subject, list) } if @jobs <= 1
33
+ return run_serially(subjects, lists) if @jobs <= 1
22
34
 
23
- pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start)
35
+ # The watcher runs as each subject's row arrives, before the pool
36
+ # hands out more work, so a tripped canary stops the queue (jobs>1
37
+ # used to check only after the whole batch returned, issue #102).
38
+ arrival = lambda do |i, row|
39
+ @after_subject&.call(subjects[i], decode(row, subjects[i], lists[i]))
40
+ end
41
+ pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start, on_result: arrival)
24
42
  rows = pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
25
43
  work(subjects[i], lists[i])
26
44
  end
@@ -29,6 +47,20 @@ module Saboteur
29
47
 
30
48
  private
31
49
 
50
+ def run_serially(subjects, lists)
51
+ results = []
52
+ subjects.zip(lists).each_with_index do |(subject, list), index|
53
+ subject_results = @scheduler.call(subject, list)
54
+ results.concat(subject_results)
55
+ @after_subject&.call(subject, subject_results)
56
+ next unless @stop_after&.call(subject_results)
57
+
58
+ @unreached = subjects.drop(index + 1)
59
+ break
60
+ end
61
+ results
62
+ end
63
+
32
64
  # Runs in a worker.
33
65
  def work(subject, list)
34
66
  before = @diagnostics.warnings.size
@@ -43,29 +75,38 @@ module Saboteur
43
75
  "status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
44
76
  "killed_by" => result.killed_by, "covered_by" => result.covered_by,
45
77
  "kill_cause" => result.kill_cause, "reused" => result.reused?,
46
- "rejected_kill" => result.rejected_kill?
78
+ "rejected_kill" => result.rejected_kill?, "executed" => result.executed
47
79
  }
48
80
  end
49
81
 
50
82
  def rebuild(row, subject, list)
51
- return failed(row["error"], subject, list) if row["error"]
83
+ if row["error"]
84
+ @diagnostics.warn("#{subject.id}: #{row['error']}")
85
+ return failed(row["error"], subject, list)
86
+ end
52
87
 
53
88
  @diagnostics.warnings.concat(row["warnings"])
89
+ decode(row, subject, list).each { |result| @cache&.record(result) unless result.reused? }
90
+ end
91
+
92
+ # Turns a worker row back into results, without diagnostics or caching:
93
+ # used both at result arrival (the canary) and at rebuild time.
94
+ def decode(row, subject, list)
95
+ return failed(row["error"], subject, list) if row["error"]
96
+
54
97
  row["results"].map do |data|
55
98
  mutation, text = list.fetch(data["position"])
56
- result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
57
- mutated_source: text, reason: data["reason"], runtime: data["runtime"],
58
- killed_by: data["killed_by"], covered_by: data["covered_by"],
59
- kill_cause: data["kill_cause"], reused: data["reused"],
60
- rejected_kill: data.fetch("rejected_kill", false))
61
- @cache&.record(result) unless result.reused?
62
- result
99
+ MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
100
+ mutated_source: text, reason: data["reason"], runtime: data["runtime"],
101
+ killed_by: data["killed_by"], covered_by: data["covered_by"],
102
+ kill_cause: data["kill_cause"], reused: data["reused"],
103
+ executed: data["executed"],
104
+ rejected_kill: data.fetch("rejected_kill", false))
63
105
  end
64
106
  end
65
107
 
66
108
  # The worker died (or raised) while running this subject.
67
109
  def failed(message, subject, list)
68
- @diagnostics.warn("#{subject.id}: #{message}")
69
110
  list.map do |mutation, text|
70
111
  MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: text,
71
112
  reason: message)
@@ -11,13 +11,16 @@ module Saboteur
11
11
  # Stryker's history files.
12
12
  #
13
13
  # A verdict is reused only while everything it was computed from is the
14
- # same: the method's source, the mutant, the set of covering tests, the
15
- # content of those tests' files, the lockfile, the run's inputs (config,
16
- # saboteur version). Only killed, survived and timeout are stored;
14
+ # same: the method's source, the whole file the method lives in (so a
15
+ # constant or helper changed elsewhere in that file is seen), the mutant,
16
+ # the set of covering tests, the content of those tests' files, the
17
+ # lockfile, the run's inputs (config, saboteur version). Only killed, survived
18
+ # and timeout are stored;
17
19
  # errors and skips always run again.
18
20
  #
19
- # Known hole, the reason this is opt-in: a change in a method this one
20
- # calls is not seen, so a stored verdict may be stale after it.
21
+ # Known hole, the reason this is opt-in: a change in another file that
22
+ # this method uses (a method it calls, a constant defined elsewhere) is
23
+ # not seen, so a stored verdict may be stale after it.
21
24
  class ResultCache
22
25
  FORMAT = 1
23
26
  STORED = %i[killed survived timeout].freeze
@@ -75,6 +78,8 @@ module Saboteur
75
78
  def fingerprint(subject, tests)
76
79
  digest = Digest::SHA256.new
77
80
  digest << VERSION << "\0" << @inputs << "\0" << @suite_digest << "\0" << subject.source << "\0"
81
+ # A hex digest has a fixed width, so it needs no separator after it.
82
+ digest << Digest::SHA256.hexdigest(subject.file_source)
78
83
  tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
79
84
  LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
80
85
  digest.hexdigest