saboteur 0.4.0 → 0.6.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. metadata +46 -2
@@ -12,10 +12,15 @@ module Saboteur
12
12
  # (VerdictCheck). Each mutant runs only the tests that executed its edit,
13
13
  # likeliest killers first, with a timeout from their recorded durations.
14
14
  class Scheduler
15
+ WHOLE_FILE_HINT = "; add it to c.whole_file to re-evaluate the whole file"
16
+
15
17
  # A subject to run, with what its mutants need. killers counts the
16
18
  # tests that killed earlier mutants of this subject; slowdown is how
17
19
  # much slower than recorded its tests ran in a child (see calibrate).
18
- Plan = Struct.new(:subject, :tests, :killers, :slowdown, keyword_init: true)
20
+ # wrapper names a known def-time wrapper (Execution::Wrapper), so the
21
+ # child re-evaluates it together with the def.
22
+ Plan = Struct.new(:subject, :tests, :killers, :slowdown, :wrapper,
23
+ keyword_init: true)
19
24
 
20
25
  # Tests that killed more earlier mutants first; otherwise keep the order.
21
26
  def self.prioritize(tests, killers)
@@ -34,20 +39,24 @@ module Saboteur
34
39
  def call(subject, candidates)
35
40
  return [] if candidates.empty?
36
41
 
37
- reason = subject.skip_reason || load_problem(subject)
42
+ reason, wrapper = subject.skip_reason ? [subject.skip_reason, nil] : load_problem(subject)
38
43
  return skip_all(candidates, subject, reason) if reason
44
+ if wrapper == :sorbet && !subject.sig_source
45
+ return skip_all(candidates, subject,
46
+ "sorbet sig not found next to the def; cannot preserve runtime type checks")
47
+ end
39
48
 
40
49
  tests = @map.tests_for(subject)
41
- return skip_all(candidates, subject, "no coverage") if tests.empty?
50
+ return unloadable(candidates, subject) if tests.empty?
42
51
 
43
- plan = Plan.new(subject:, tests:, killers: Hash.new(0), slowdown: 1.0)
52
+ plan = Plan.new(subject:, tests:, killers: Hash.new(0), slowdown: 1.0, wrapper: wrapper)
44
53
  reused = all_reused(plan, candidates)
45
54
  return reused if reused
46
55
 
47
56
  results = run_candidates(candidates) do |mutation, text|
48
57
  reuse(plan, mutation, text) || execute(plan, mutation, text)
49
58
  end
50
- checked(subject, results)
59
+ checked(plan, results)
51
60
  end
52
61
 
53
62
  # The per-mutant timeout: timeout_factor times what the tests took
@@ -64,9 +73,11 @@ module Saboteur
64
73
 
65
74
  # Verdicts that did not hold up are warned about once per subject;
66
75
  # only checked verdicts are stored for --incremental.
67
- def checked(subject, results)
76
+ def checked(plan, results)
77
+ subject = plan.subject
68
78
  run_unchanged = lambda do |tests, timeout|
69
- @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: timeout)
79
+ @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: timeout,
80
+ eval_prefix: eval_prefix(plan))
70
81
  .tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
71
82
  end
72
83
  check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
@@ -100,7 +111,7 @@ module Saboteur
100
111
  return nil unless @cache
101
112
 
102
113
  tests = tests_for(plan, mutation)
103
- return skip_all([[mutation, text]], plan.subject, "no coverage").first if tests.empty?
114
+ return uncoverable(plan, mutation, text) if tests.empty?
104
115
 
105
116
  hit = @cache.lookup(plan.subject, mutation, tests)
106
117
  return nil unless hit
@@ -112,14 +123,14 @@ module Saboteur
112
123
 
113
124
  def execute(plan, mutation, text)
114
125
  tests = tests_for(plan, mutation)
115
- return skip_all([[mutation, text]], plan.subject, "no coverage").first if tests.empty?
126
+ return uncoverable(plan, mutation, text) if tests.empty?
116
127
 
117
- timeout = timeout_for(tests, plan.slowdown)
118
- outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout, mutation: mutation)
119
- outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
128
+ outcome = run_mutant(plan, mutation, text, tests)
129
+ note_unreachable(plan, outcome) if outcome.status == :unreachable
120
130
  result = MutantResult.new(subject: plan.subject, mutation: mutation, status: outcome.status,
121
131
  mutated_source: text, reason: outcome.message, runtime: outcome.runtime,
122
- killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause)
132
+ killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
133
+ executed: outcome.executed_ids)
123
134
  outcome.failed_ids.each { |id| plan.killers[id] += 1 }
124
135
  note_leftovers(plan.subject, "#{mutation.operator} #{result.digest}", outcome)
125
136
  @diagnostics.progress(result)
@@ -130,8 +141,12 @@ module Saboteur
130
141
  # body, so it gets every test of the method), prior killers first, then
131
142
  # fastest-first.
132
143
  def tests_for(plan, mutation)
144
+ return self.class.prioritize(plan.tests, plan.killers) if mutation.coarse
145
+
133
146
  line_numbers = mutation.edits.flat_map { |edit| plan.subject.coverage_lines_for(edit) }.uniq
134
- tests = plan.tests & @map.tests_for_lines(plan.subject.file, line_numbers)
147
+ tests = plan.tests & @map.tests_for_edit(plan.subject, line_numbers)
148
+ branch_tests = @map.tests_for_branches(plan.subject, mutation.edits)
149
+ tests &= branch_tests if branch_tests
135
150
  self.class.prioritize(tests, plan.killers)
136
151
  end
137
152
 
@@ -139,6 +154,52 @@ module Saboteur
139
154
  [@config["timeout_factor"] * runtime, @config["timeout_min"]].max
140
155
  end
141
156
 
157
+ # What a subject no test ran gets. Code that only ran while the app
158
+ # loaded is not "no coverage" - it ran - but no forked test can re-run
159
+ # it, so it gets its own status, out of the score, with a hint.
160
+ def unloadable(candidates, subject, line_numbers = nil)
161
+ return skip_all(candidates, subject, "no coverage") unless
162
+ @map.ran_at_load?(subject.file, line_numbers || subject.coverage_lines)
163
+
164
+ candidates.map do |mutation, text|
165
+ MutantResult.new(subject: subject, mutation: mutation, status: :ran_at_load, mutated_source: text,
166
+ reason: Selection::CoverageMap::RAN_AT_LOAD)
167
+ end
168
+ end
169
+
170
+ # One mutation whose edited lines no test ran (see unloadable).
171
+ def uncoverable(plan, mutation, text)
172
+ line_numbers = mutation.edits.flat_map { |edit| plan.subject.coverage_lines_for(edit) }.uniq
173
+ result, = unloadable([[mutation, text]], plan.subject, line_numbers)
174
+ return result if result.status == :ran_at_load
175
+ return result unless @map.tests_for_branches(plan.subject, mutation.edits) == []
176
+
177
+ skipped, = skip_all([[mutation, text]], plan.subject, "no coverage (branch not taken)")
178
+ skipped
179
+ end
180
+
181
+ def run_mutant(plan, mutation, text, tests)
182
+ timeout = timeout_for(tests, plan.slowdown)
183
+ outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout,
184
+ mutation: mutation, eval_prefix: eval_prefix(plan))
185
+ outcome.status == :timeout ? calibrate(plan, mutation, text, tests, timeout) : outcome
186
+ end
187
+
188
+ # Each mutant is checked separately: changing a body can change which
189
+ # definition a test reaches, so one unreachable verdict cannot skip later work.
190
+ def note_unreachable(plan, outcome)
191
+ @diagnostics.warn("#{plan.subject.id}: #{outcome.message}")
192
+ end
193
+
194
+ # The text a known def-time wrapper needs re-evaluated with the def.
195
+ # Sorbet's sig sits on the lines right before it; without it the def
196
+ # evaluates alone and runtime type checks silently drop for the child.
197
+ def eval_prefix(plan)
198
+ return nil unless plan.wrapper == :sorbet
199
+
200
+ plan.subject.sig_source
201
+ end
202
+
142
203
  # Recorded durations come from one warm process; a fresh child can be
143
204
  # slower (lazy loading, first queries). So a timeout is checked at
144
205
  # once: the unchanged method runs the same tests with
@@ -147,7 +208,7 @@ module Saboteur
147
208
  # the measured slowdown. A timeout that remains is the mutant's own.
148
209
  def calibrate(plan, mutation, text, tests, timeout)
149
210
  unchanged = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
150
- timeout: @config["baseline_timeout"])
211
+ timeout: @config["baseline_timeout"], eval_prefix: eval_prefix(plan))
151
212
  note_leftovers(plan.subject, "timeout check", unchanged)
152
213
  return unchanged_failed(unchanged) unless unchanged.status == :survived
153
214
 
@@ -156,7 +217,8 @@ module Saboteur
156
217
  longer = deadline(unchanged.runtime)
157
218
  return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
158
219
 
159
- @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: longer, mutation: mutation)
220
+ @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: longer, mutation: mutation,
221
+ eval_prefix: eval_prefix(plan))
160
222
  end
161
223
 
162
224
  def unchanged_failed(unchanged)
@@ -167,18 +229,28 @@ module Saboteur
167
229
  end
168
230
 
169
231
  # The method saboteur would redefine must be the one Ruby is running.
232
+ # Returns [skip reason, wrapper]: a known def-time wrapper (sorbet's
233
+ # sig is one, see Execution::Wrapper) is not a shadowing - the child
234
+ # re-evaluates it together with the def, so the check passes.
170
235
  def load_problem(subject)
236
+ return [nil, nil] if @reevaluator.whole_file?(subject)
237
+
171
238
  owner = @reevaluator.resolve_owner(subject)
172
239
  target = subject.singleton ? owner.singleton_class : owner
173
240
  method = own_method(target, subject.name)
174
- return "method not loaded" unless method
241
+ return ["method not loaded", nil] unless method
175
242
 
176
243
  file, line = method.source_location
177
- return nil if file && Selection::CoverageMap.canonical(file) == subject.file && line == subject.start_line
244
+ if file && Selection::CoverageMap.canonical(file) == subject.file && line == subject.start_line
245
+ return [nil, nil]
246
+ end
247
+
248
+ wrapper = Execution::Wrapper.around(method, file)
249
+ return [nil, wrapper] if wrapper
178
250
 
179
- "shadowed definition (runtime method at #{file}:#{line})"
251
+ ["shadowed definition (runtime method at #{file}:#{line})#{WHOLE_FILE_HINT}", nil]
180
252
  rescue Error => e
181
- "owner not loaded: #{e.message}"
253
+ ["owner not loaded: #{e.message}#{WHOLE_FILE_HINT}", nil]
182
254
  end
183
255
 
184
256
  def own_method(target, name)
@@ -33,7 +33,9 @@ module Saboteur
33
33
  def find(files)
34
34
  finder = SubjectFinder.new
35
35
  files.flat_map do |file|
36
+ InlineSuppression.new(file, File.read(file))
36
37
  parsed = finder.call(file)
38
+ @diagnostics.record_discovery_failure(file, reason: "invalid_file_syntax") unless parsed.errors.empty?
37
39
  parsed.errors.first(1).each do |message|
38
40
  @diagnostics.warn("#{file.delete_prefix("#{@root}/")} does not parse: #{message}")
39
41
  end
@@ -54,9 +56,30 @@ module Saboteur
54
56
  def by_change(subjects, ref)
55
57
  @changed_lines = Selection::GitDiff.changed_lines(ref, root: @root)
56
58
  .transform_keys { |file| Selection::CoverageMap.canonical(file) }
57
- subjects.select do |subject|
59
+ changed = subjects.select do |subject|
58
60
  @changed_lines[subject.file]&.any? { |line| subject.contains_line?(line) }
59
61
  end
62
+ drop_unchanged_syntax(changed, ref)
63
+ end
64
+
65
+ # A method whose only changes are comments, blank lines or spacing has
66
+ # nothing new to test.
67
+ def drop_unchanged_syntax(subjects, ref)
68
+ return subjects if subjects.empty?
69
+
70
+ comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(ref, root: @root), root: @root)
71
+ same, kept = subjects.partition { |subject| comparison.unchanged?(subject) }
72
+ report_unchanged_syntax(same)
73
+ kept
74
+ end
75
+
76
+ def report_unchanged_syntax(subjects)
77
+ return if subjects.empty?
78
+
79
+ names = subjects.first(5).map(&:id).join(", ")
80
+ more = subjects.size > 5 ? ", ..." : ""
81
+ @diagnostics.inform("#{subjects.size} changed method(s) differ only in comments or formatting, " \
82
+ "so they are skipped: #{names}#{more}")
60
83
  end
61
84
  end
62
85
  end
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # --guess-tests: the specs that sit next to the changed source files by
7
+ # naming convention (lib/a/b.rb -> spec/a/b_spec.rb). A guess, not a
8
+ # coverage fact: a spec that only reaches the file through another one
9
+ # is not found. Packwerk packs (a directory with a package.yml) map to the
10
+ # spec directory inside the same pack, and controllers also map to request
11
+ # specs. The run scopes to the guesses only when every changed file has one.
12
+ class TestGuess
13
+ Result = Data.define(:specs, :unmatched, :changed)
14
+
15
+ SOURCE_ROOTS = %w[lib app].freeze
16
+
17
+ def initialize(config)
18
+ @root = File.realpath(config.root)
19
+ @config = config
20
+ end
21
+
22
+ # specs: existing spec files (relative paths); unmatched: changed
23
+ # source files with no guessed spec; changed: how many source files
24
+ # changed.
25
+ def call
26
+ changed = changed_sources
27
+ guesses = changed.to_h { |file| [file, guess_for(file)] }
28
+ Result.new(specs: guesses.values.compact.uniq.sort, unmatched: guesses.select { |_, spec| spec.nil? }.keys,
29
+ changed: changed.size)
30
+ end
31
+
32
+ private
33
+
34
+ # Changed files that saboteur would mutate, relative to the root.
35
+ def changed_sources
36
+ included = @config.included_files.to_set
37
+ changed = Selection::GitDiff.changed_lines(@config["since"], root: @root).keys
38
+ changed.map { |file| Selection::CoverageMap.canonical(file) }
39
+ .select { |file| included.include?(file) }
40
+ .map { |file| file.delete_prefix("#{@root}/") }.sort
41
+ end
42
+
43
+ def guess_for(file)
44
+ candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
45
+ end
46
+
47
+ # Candidate specs for a source file, most specific first: the nearest
48
+ # package.yml pack's own spec directory, then the app-wide one.
49
+ def candidates_for(file)
50
+ pack = pack_of(file)
51
+ # pack is nil for files outside packs: "#{nil}/" is "/", which
52
+ # never prefixes a relative path, so delete_prefix is a no-op there.
53
+ base = file.delete_prefix("#{pack}/")
54
+ spec_root = pack ? "#{pack}/spec" : "spec"
55
+ stem = base.delete_suffix(".rb")
56
+ rest = stem.split("/", 2)
57
+ rooted = rest.size == 2 && SOURCE_ROOTS.include?(rest.first)
58
+ names = rooted ? [rest.last, "lib/#{rest.last}", stem] : [stem]
59
+ names = names.flat_map { |name| [name, *request_names(name)] }.uniq
60
+ names.map { |name| "#{spec_root}/#{name}_spec.rb" }
61
+ end
62
+
63
+ # Rails tests controllers as request specs: spec/requests/users_spec.rb
64
+ # or spec/requests/users_controller_spec.rb for controllers/users_controller.rb.
65
+ def request_names(name)
66
+ return [] unless name.start_with?("controllers/")
67
+
68
+ path = name.delete_prefix("controllers/")
69
+ ["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
70
+ end
71
+
72
+ # The nearest ancestor directory holding a package.yml (a packwerk
73
+ # pack), relative to the root. The root itself is not a pack here:
74
+ # its specs follow the plain mapping.
75
+ def pack_of(file)
76
+ dir = File.dirname(file)
77
+ until dir == "."
78
+ return dir if pack_dirs.include?(dir)
79
+
80
+ dir = File.dirname(dir)
81
+ end
82
+ end
83
+
84
+ def pack_dirs
85
+ @pack_dirs ||= Dir.glob("**/package.yml", base: @root).to_set { |path| File.dirname(path) }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,87 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "../inline_suppression"
6
+
7
+ module Saboteur
8
+ class Engine
9
+ # Tells whether a method still has, in the working tree, the syntax it had
10
+ # at the merge base of the --since ref. A change to a comment, a blank
11
+ # line or spacing leaves it the same, so no mutant of it asks a question
12
+ # the change raised. Comparison is of the Prism tree without locations
13
+ # and comments, plus the frozen_string_literal magic comment and the
14
+ # `sig` in front of the def, which both change what runs. `__LINE__` is
15
+ # the one node whose value is its location, so it keeps its line: a
16
+ # method that moved, or one with a line added above its `__LINE__`, is
17
+ # not the same.
18
+ #
19
+ # Anything that cannot be compared says the method changed: a file or
20
+ # method that is new at the base, a base that does not parse, a method
21
+ # name defined twice in a file.
22
+ class UnchangedSyntax
23
+ def initialize(base, root:)
24
+ @base = base
25
+ @root = root
26
+ @base_subjects = {}
27
+ @current_subjects = {}
28
+ end
29
+
30
+ def unchanged?(subject)
31
+ before = base_subject(subject)
32
+ return false unless before
33
+
34
+ return false unless suppression_signature(before) == suppression_signature(subject)
35
+
36
+ before.frozen_literals == subject.frozen_literals && before.sig_source == subject.sig_source &&
37
+ self.class.digest(before.node) == self.class.digest(subject.node)
38
+ end
39
+
40
+ # The node as plain data: type and fields, never locations (except
41
+ # the line a `__LINE__` stands for).
42
+ def self.digest(value)
43
+ case value
44
+ when Prism::SourceLineNode then [value.type, value.location.start_line]
45
+ when Prism::Node
46
+ fields = value.deconstruct_keys(nil).except(:node_id)
47
+ [value.type, fields.transform_values { |field| digest(field) }]
48
+ when Array then value.map { |item| digest(item) }
49
+ when Prism::Location then nil
50
+ else value
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def suppression_signature(subject)
57
+ InlineSuppression.new(subject.file, subject.file_source).signature(subject)
58
+ end
59
+
60
+ def base_subject(subject)
61
+ return nil unless current_subjects(subject).key?(subject.id)
62
+
63
+ base_subjects(subject.file)[subject.id]
64
+ end
65
+
66
+ # Only the names defined once: with two defs of a name, which one a
67
+ # change belongs to is a guess.
68
+ def unique_by_id(source, file)
69
+ subjects = SubjectFinder.new.call(file, source: source).subjects
70
+ counts = subjects.map(&:id).tally
71
+ subjects.select { |subject| counts[subject.id] == 1 }.to_h { |subject| [subject.id, subject] }
72
+ end
73
+
74
+ def current_subjects(subject)
75
+ file = subject.file
76
+ @current_subjects.fetch(file) { @current_subjects[file] = unique_by_id(subject.file_source, file) }
77
+ end
78
+
79
+ def base_subjects(file)
80
+ @base_subjects.fetch(file) do
81
+ text = Selection::GitDiff.file_at(@base, file.delete_prefix("#{@root}/"), root: @root)
82
+ @base_subjects[file] = text ? unique_by_id(text, file) : {}
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -5,7 +5,7 @@ module Saboteur
5
5
  class Engine
6
6
  # Checks each kill against the method re-evaluated unchanged: a kill
7
7
  # stands only if the same tests, in the same order, up to and including
8
- # the killer, pass without the mutant (so a test that fails only after
8
+ # the last killer, pass without the mutant (so a test that fails only after
9
9
  # another test left state behind cannot claim a kill). A kill with no
10
10
  # failing test named (an exit, a suite-level failure) is checked on all
11
11
  # the mutant's tests. A kill that fails the check becomes an error.
@@ -39,11 +39,12 @@ module Saboteur
39
39
  result.status == :killed && result.killed_by.empty?
40
40
  end
41
41
 
42
- # The tests the mutant ran, in order, up to and including the killer.
42
+ # The tests the mutant ran, in order, up to and including the last
43
+ # killer: a matrix run lists every failing test, and each of them must
44
+ # pass under the unchanged method in this same sequence.
43
45
  def sequence(result)
44
- killer = result.killed_by.first
45
- position = result.covered_by.index(killer)
46
- position ? result.covered_by.first(position + 1) : [killer]
46
+ position = result.killed_by.filter_map { |test| result.covered_by.index(test) }.max
47
+ position ? result.covered_by.first(position + 1) : [result.killed_by.first]
47
48
  end
48
49
 
49
50
  # Runs each distinct kill sequence once. Returns those that fail, each
@@ -67,7 +68,7 @@ module Saboteur
67
68
 
68
69
  if named_kill?(result) && failing_runs.key?(sequence(result))
69
70
  why = failing_runs[sequence(result)]
70
- result.as_error("killing test #{result.killed_by.first} also fails with the method unchanged" \
71
+ result.as_error("killing test #{sequence(result).last} also fails with the method unchanged" \
71
72
  "#{": #{why}" if why}", rejected_kill: true)
72
73
  elsif failing.include?(result)
73
74
  result.as_error("tests also fail with the method unchanged", rejected_kill: true)