saboteur 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/README.md +393 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +101 -121
- data/lib/saboteur/config.rb +76 -6
- data/lib/saboteur/doctor.rb +40 -8
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +6 -4
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +192 -18
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +31 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/reporter/agent_commands.rb +7 -1
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +11 -5
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +15 -2
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +131 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +41 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +43 -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
126
|
+
return uncoverable(plan, mutation, text) if tests.empty?
|
|
116
127
|
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
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
|
-
|
|
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,52 @@
|
|
|
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. The run scopes to the guesses only when every changed
|
|
10
|
+
# file has one.
|
|
11
|
+
class TestGuess
|
|
12
|
+
Result = Data.define(:specs, :unmatched, :changed)
|
|
13
|
+
|
|
14
|
+
SOURCE_ROOTS = %w[lib app].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@root = File.realpath(config.root)
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# specs: existing spec files (relative paths); unmatched: changed
|
|
22
|
+
# source files with no guessed spec; changed: how many source files
|
|
23
|
+
# changed.
|
|
24
|
+
def call
|
|
25
|
+
changed = changed_sources
|
|
26
|
+
guesses = changed.to_h { |file| [file, guess_for(file)] }
|
|
27
|
+
Result.new(specs: guesses.values.compact.uniq.sort, unmatched: guesses.select { |_, spec| spec.nil? }.keys,
|
|
28
|
+
changed: changed.size)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Changed files that saboteur would mutate, relative to the root.
|
|
34
|
+
def changed_sources
|
|
35
|
+
included = @config.included_files.to_set
|
|
36
|
+
changed = Selection::GitDiff.changed_lines(@config["since"], root: @root).keys
|
|
37
|
+
changed.map { |file| Selection::CoverageMap.canonical(file) }
|
|
38
|
+
.select { |file| included.include?(file) }
|
|
39
|
+
.map { |file| file.delete_prefix("#{@root}/") }.sort
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def guess_for(file)
|
|
43
|
+
stem = file.delete_suffix(".rb")
|
|
44
|
+
rest = stem.split("/", 2)
|
|
45
|
+
candidates = ["spec/#{stem}_spec.rb"]
|
|
46
|
+
candidates.unshift("spec/#{rest.last}_spec.rb", "spec/lib/#{rest.last}_spec.rb") if
|
|
47
|
+
rest.size == 2 && SOURCE_ROOTS.include?(rest.first)
|
|
48
|
+
candidates.find { |path| File.file?(File.join(@root, path)) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
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
|
|
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
|
-
|
|
45
|
-
position
|
|
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.
|
|
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)
|