saboteur 0.7.0 → 0.8.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 +657 -0
- data/README.md +198 -1077
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +6 -2
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +120 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +13 -0
- metadata +35 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "subject"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# A constant assigned a hash or array literal, as a mutation subject
|
|
8
|
+
# (issue #306): `RULES = { ... }` or `TAGS = [ ... ]`, with an optional
|
|
9
|
+
# trailing `.freeze`. Only --since selection creates these (see
|
|
10
|
+
# Engine::SubjectSelection), and the diff scope narrows their mutants to
|
|
11
|
+
# the entries the diff changed.
|
|
12
|
+
#
|
|
13
|
+
# A constant is not a method, so the surgical def re-evaluation does not
|
|
14
|
+
# apply: mutants run by re-evaluating the whole file with the mutated
|
|
15
|
+
# assignment spliced in (Execution::Reevaluator#eval_whole_file, which
|
|
16
|
+
# silences the "already initialized constant" warning). Re-evaluation
|
|
17
|
+
# replaces the constant's object; code that reads the constant at test
|
|
18
|
+
# time sees the mutant, while anything that copied the old object at load
|
|
19
|
+
# time keeps it. consumer_lines names the reads a test can cover; the
|
|
20
|
+
# coverage map turns them into the mutant's tests.
|
|
21
|
+
class ConstantSubject < Subject
|
|
22
|
+
attr_reader :literal_node
|
|
23
|
+
# { "/abs/file.rb" => [lines reading the constant] } across the include
|
|
24
|
+
# set, filled in after selection (the finder sees one file at a time).
|
|
25
|
+
attr_accessor :consumer_lines
|
|
26
|
+
# { "/abs/file.rb" => [def start lines] }: the def each runtime read
|
|
27
|
+
# sits in, for the method-call counter fallback (endless defs have no
|
|
28
|
+
# line coverage).
|
|
29
|
+
attr_accessor :consumer_defs
|
|
30
|
+
|
|
31
|
+
def initialize(name:, owner:, file:, node:, literal_node:, file_source:, nesting:, frozen_literals:,
|
|
32
|
+
consumer_lines: {}, consumer_defs: {})
|
|
33
|
+
super(name:, owner:, singleton: false, sclass: false, file:, node:, file_source:, nesting:,
|
|
34
|
+
frozen_literals:, sig_span: nil)
|
|
35
|
+
@literal_node = literal_node
|
|
36
|
+
@consumer_lines = consumer_lines
|
|
37
|
+
@consumer_defs = consumer_defs
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# "Shop::RULES", or "RULES" at the top level. The :: separator keeps a
|
|
41
|
+
# constant's id apart from the Klass#method and Klass.method spellings.
|
|
42
|
+
def id
|
|
43
|
+
owner ? "#{owner}::#{name}" : name.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# There is no method body to redefine: constants always take the
|
|
47
|
+
# whole-file evaluation path.
|
|
48
|
+
def whole_file_only?
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The assignment runs while the file loads, so its lines carry the load
|
|
53
|
+
# evidence; the reads of the constant in this file are the lines tests
|
|
54
|
+
# can cover.
|
|
55
|
+
def coverage_lines
|
|
56
|
+
((start_line..end_line).to_a + Array(consumer_lines[file])).uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every edit sits inside the literal and is observable wherever the
|
|
60
|
+
# constant is read, so the assignment's own lines are the evidence that
|
|
61
|
+
# the edit ran (they execute at load, never inside a test).
|
|
62
|
+
def coverage_lines_for(_edit)
|
|
63
|
+
(start_line..end_line).to_a
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def recovers?
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "subject"
|
|
6
|
+
require_relative "constant_subject"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
# Finds constant assignments of hash and array literals - `RULES = { ... }`,
|
|
10
|
+
# `TAGS = [ ... ]`, with a trailing `.freeze` or a similar argument-free,
|
|
11
|
+
# block-free call - as mutation subjects for --since runs (issue #306).
|
|
12
|
+
#
|
|
13
|
+
# Unsupported forms are reported with a reason, never silently skipped: a
|
|
14
|
+
# namespaced target (`A::RULES = ...`), a conditional write
|
|
15
|
+
# (`RULES ||= ...`), and a non-literal value (a method call with
|
|
16
|
+
# arguments, another constant). Class factories (`Point = Data.define(...)`)
|
|
17
|
+
# are scopes, not data: they are reported neither way, and a constant
|
|
18
|
+
# inside the factory's block binds on the enclosing lexical scope, never
|
|
19
|
+
# on the class the factory builds (#352, mirroring SubjectFinder).
|
|
20
|
+
# Constants inside any other block are found but marked skipped, like
|
|
21
|
+
# SubjectFinder's defs, so the report names them.
|
|
22
|
+
class ConstantSubjectFinder
|
|
23
|
+
Parsed = Data.define(:subjects, :unsupported, :errors)
|
|
24
|
+
|
|
25
|
+
# A constant assignment saboteur cannot mutate, with the reason why.
|
|
26
|
+
Unsupported = Data.define(:name, :owner, :file, :node, :reason) do
|
|
27
|
+
def id
|
|
28
|
+
owner ? "#{owner}::#{name}" : name.to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def overlaps_lines?(lines)
|
|
32
|
+
first = node.location.start_line
|
|
33
|
+
last = node.location.end_line
|
|
34
|
+
if lines.is_a?(Range)
|
|
35
|
+
lines.begin <= last && first <= lines.end
|
|
36
|
+
else
|
|
37
|
+
lines.any? { |line| line.between?(first, last) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
CLASS_FACTORIES = SubjectFinder::CLASS_FACTORIES
|
|
43
|
+
|
|
44
|
+
# source: the text to parse when it is not the file on disk (the file as
|
|
45
|
+
# it was at an earlier commit).
|
|
46
|
+
def call(file, source: File.binread(file))
|
|
47
|
+
result = Prism.parse(source)
|
|
48
|
+
return Parsed.new(subjects: [], unsupported: [], errors: result.errors.map(&:message)) if result.failure?
|
|
49
|
+
|
|
50
|
+
ctx = { file: file, source: source, frozen: frozen_literals?(result) }
|
|
51
|
+
out = { subjects: [], unsupported: [], ambiguous: [] }
|
|
52
|
+
walk(result.value, [], out, **ctx)
|
|
53
|
+
reject_duplicates(out, **ctx)
|
|
54
|
+
demote_ambiguous(out, **ctx)
|
|
55
|
+
Parsed.new(subjects: out[:subjects], unsupported: out[:unsupported], errors: [])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# More than one write of the same constant in one file: re-evaluating
|
|
61
|
+
# the file runs them all, the later one always wins, and a mutant of
|
|
62
|
+
# any earlier literal is never observed. EVERY write counts - a guarded
|
|
63
|
+
# or non-literal second write (`RULES = {a: 1}; RULES = build_rules`)
|
|
64
|
+
# overwrites the first just the same (#319).
|
|
65
|
+
def reject_duplicates(out, **ctx)
|
|
66
|
+
writes = Hash.new(0)
|
|
67
|
+
(out[:subjects] + out[:unsupported]).each { |entry| writes[entry.id] += 1 }
|
|
68
|
+
duplicates = out[:subjects].select { |subject| writes[subject.id] > 1 }
|
|
69
|
+
return if duplicates.empty?
|
|
70
|
+
|
|
71
|
+
out[:subjects] -= duplicates
|
|
72
|
+
duplicates.each do |subject|
|
|
73
|
+
out[:unsupported] << Unsupported.new(name: subject.name, owner: subject.owner, file: ctx[:file],
|
|
74
|
+
node: subject.node,
|
|
75
|
+
reason: "assigned more than once in this file")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def frozen_literals?(result)
|
|
80
|
+
result.magic_comments.any? do |comment|
|
|
81
|
+
comment.key.tr("-", "_").casecmp?("frozen_string_literal") && comment.value.casecmp?("true")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def walk(node, nesting, out, **ctx)
|
|
86
|
+
return if node.nil?
|
|
87
|
+
|
|
88
|
+
case node
|
|
89
|
+
when Prism::BlockNode, Prism::LambdaNode
|
|
90
|
+
walk(node.body, nesting, out, **ctx, in_block: true)
|
|
91
|
+
when Prism::ConstantWriteNode
|
|
92
|
+
constant_assignment(node, nesting, out, **ctx)
|
|
93
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
94
|
+
walk_conditional(node, nesting, out, **ctx)
|
|
95
|
+
when Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
|
|
96
|
+
record_unsupported(out, node, nesting, "a conditional assignment (#{node.operator_loc.slice})", **ctx)
|
|
97
|
+
walk_children(node, nesting, out, **ctx)
|
|
98
|
+
when Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
|
|
99
|
+
record_unsupported(out, node, nesting, "a namespaced target (#{node.target.slice})", **ctx)
|
|
100
|
+
walk_children(node, nesting, out, **ctx)
|
|
101
|
+
# A keyword class or module re-enters the lexical scope: its constants
|
|
102
|
+
# are ordinary subjects even inside a block, and factory scopes never
|
|
103
|
+
# join their paths (#352).
|
|
104
|
+
when Prism::ModuleNode, Prism::ClassNode
|
|
105
|
+
kind = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
106
|
+
scope = Scope.new(kind: kind, path: node.constant_path.slice)
|
|
107
|
+
walk(node.body, lexical_nesting(nesting) + [scope], out, **ctx, in_block: false)
|
|
108
|
+
when Prism::SingletonClassNode
|
|
109
|
+
walk_singleton_class(node, nesting, out, **ctx)
|
|
110
|
+
else
|
|
111
|
+
walk_children(node, nesting, out, **ctx)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A write under a condition may never run: mutating its literal cannot
|
|
116
|
+
# change the constant when the guard skips the write (#319).
|
|
117
|
+
def walk_conditional(node, nesting, out, **ctx)
|
|
118
|
+
walk(node.predicate, nesting, out, **ctx)
|
|
119
|
+
walk(node.statements, nesting, out, **ctx, guarded: true)
|
|
120
|
+
walk(node.respond_to?(:subsequent) ? node.subsequent : node.else_clause, nesting, out, **ctx,
|
|
121
|
+
guarded: true)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def walk_children(node, nesting, out, **ctx)
|
|
125
|
+
node.compact_child_nodes.each { |child| walk(child, nesting, out, **ctx) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `class << self` opens the attached class's singleton scope: constants
|
|
129
|
+
# assigned there bind on the singleton class, so the scope joins the
|
|
130
|
+
# nesting and owner_path renders it "<attached>.singleton_class" (#380,
|
|
131
|
+
# mirroring SubjectFinder). Unlike a keyword class, `class << self`
|
|
132
|
+
# does not reset in_block: a block's runtime self is unknowable at
|
|
133
|
+
# parse time (an `included do` block runs with the including class as
|
|
134
|
+
# self), so its constants stay conservatively skipped inside a block.
|
|
135
|
+
# A non-self receiver, a nested singleton class or the top level has no
|
|
136
|
+
# statically nameable singleton: the enclosing nesting stays.
|
|
137
|
+
def walk_singleton_class(node, nesting, out, **ctx)
|
|
138
|
+
unless node.expression.is_a?(Prism::SelfNode) && nesting.any? && !nesting.last.sclass?
|
|
139
|
+
return walk_children(node, nesting, out, **ctx)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
walk(node.body, nesting + [Scope.new(kind: :sclass, path: "self")], out, **ctx)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def constant_assignment(node, nesting, out, **ctx)
|
|
146
|
+
return walk_factory(node, nesting, out, **ctx) if factory_scope?(node)
|
|
147
|
+
|
|
148
|
+
literal = literal_value(node.value)
|
|
149
|
+
reason = unsupported_reason(node, literal, **ctx)
|
|
150
|
+
if reason
|
|
151
|
+
record_unsupported(out, node, nesting, reason, **ctx)
|
|
152
|
+
walk_children(node, nesting, out, **ctx)
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
subject = ConstantSubject.new(name: node.name, owner: owner_path(nesting), file: ctx[:file], node: node,
|
|
157
|
+
literal_node: literal, file_source: ctx[:source], nesting: nesting,
|
|
158
|
+
frozen_literals: ctx[:frozen])
|
|
159
|
+
subject.skip_reason = "defined inside a block" if ctx[:in_block]
|
|
160
|
+
out[:subjects] << subject
|
|
161
|
+
# Another constant can hide in the literal's values (`MAP = { x: (Y = []) }`).
|
|
162
|
+
walk_children(node, nesting, out, **ctx)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# `Point = Data.define(:x) do ... end`: the receiver and arguments stay
|
|
166
|
+
# in the enclosing scope. The block's body is walked directly, not as a
|
|
167
|
+
# block, and the factory opens no lexical constant scope: a constant
|
|
168
|
+
# inside the block binds on the enclosing lexical scope and is an
|
|
169
|
+
# ordinary subject (#352, mirroring SubjectFinder).
|
|
170
|
+
def walk_factory(node, nesting, out, **ctx)
|
|
171
|
+
call = node.value
|
|
172
|
+
(call.compact_child_nodes - [call.block]).each { |child| walk(child, nesting, out, **ctx) }
|
|
173
|
+
walk(call.block.body, lexical_nesting(nesting) + [Scope.new(kind: :factory, path: node.name.to_s)], out, **ctx)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A factory scope names the class being built but opens no lexical
|
|
177
|
+
# constant scope, so it never joins a constant's path (#339).
|
|
178
|
+
def lexical_nesting(nesting)
|
|
179
|
+
nesting.reject(&:factory?)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def unsupported_reason(_node, literal, guarded: false, **)
|
|
183
|
+
return "guarded by a condition" if guarded
|
|
184
|
+
return "its value is not a hash or array literal" if literal.nil?
|
|
185
|
+
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# `Point = Data.define(...) do ... end`: only the four core factories
|
|
190
|
+
# count, called on the bare constant (mirrors SubjectFinder).
|
|
191
|
+
def factory_scope?(node)
|
|
192
|
+
call = node.value
|
|
193
|
+
return false unless call.is_a?(Prism::CallNode) && call.block.is_a?(Prism::BlockNode)
|
|
194
|
+
return false unless call.receiver.is_a?(Prism::ConstantReadNode)
|
|
195
|
+
|
|
196
|
+
CLASS_FACTORIES[call.receiver.name] == call.name
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The hash or array literal behind the value, looking through `.freeze`
|
|
200
|
+
# calls only: any other receiver chain transforms the value, so the
|
|
201
|
+
# literal is not what the constant holds.
|
|
202
|
+
def literal_value(value)
|
|
203
|
+
value = value.receiver while value.is_a?(Prism::CallNode) && value.name == :freeze &&
|
|
204
|
+
value.arguments.nil? && value.block.nil? && value.receiver
|
|
205
|
+
value if value.is_a?(Prism::HashNode) || value.is_a?(Prism::ArrayNode)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def record_unsupported(out, node, nesting, reason, **ctx)
|
|
209
|
+
# A bare target takes the lexical owner. A ROOTED qualified target names
|
|
210
|
+
# its constant absolutely ("::RULES" is RULES anywhere). An UNROOTED one
|
|
211
|
+
# resolves through the lexical scope: "Shop::RULES" inside
|
|
212
|
+
# `module Outer; module Shop` writes Outer::Shop::RULES. Every id on the
|
|
213
|
+
# write's scope chain is a candidate, so each goes on the ambiguous list
|
|
214
|
+
# for demotion after the walk; over-matching only demotes a subject.
|
|
215
|
+
if node.respond_to?(:name)
|
|
216
|
+
name = node.name
|
|
217
|
+
owner = owner_path(nesting)
|
|
218
|
+
else
|
|
219
|
+
name = node.target.slice.sub(/\A::/, "")
|
|
220
|
+
owner = nil
|
|
221
|
+
unless node.target.slice.start_with?("::")
|
|
222
|
+
nesting.length.downto(0) do |depth|
|
|
223
|
+
prefix = owner_path(nesting[0...depth])
|
|
224
|
+
out[:ambiguous] << (prefix ? "#{prefix}::#{name}" : name)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
out[:unsupported] << Unsupported.new(name: name, owner: owner, file: ctx[:file],
|
|
229
|
+
node: node, reason: reason)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A subject whose id an unrooted qualified write may resolve to cannot be
|
|
233
|
+
# mutated safely: replaying the file lets that write overwrite it.
|
|
234
|
+
def demote_ambiguous(out, **ctx)
|
|
235
|
+
return if out[:ambiguous].empty?
|
|
236
|
+
|
|
237
|
+
poisoned = out[:subjects].select { |subject| out[:ambiguous].include?(subject.id) }
|
|
238
|
+
out[:subjects] -= poisoned
|
|
239
|
+
poisoned.each do |subject|
|
|
240
|
+
out[:unsupported] << Unsupported.new(name: subject.name, owner: subject.owner, file: ctx[:file],
|
|
241
|
+
node: subject.node,
|
|
242
|
+
reason: "an unrooted qualified write may overwrite it")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Lexical path of the enclosing class/module, or nil at the top level.
|
|
247
|
+
# A singleton-class scope renders as "<attached>.singleton_class":
|
|
248
|
+
# constants assigned in `class << self` bind on the singleton class,
|
|
249
|
+
# not on the attached class (#380). Factory scopes open no lexical
|
|
250
|
+
# constant scope, but a factory's block is class_eval'd, so a `class <<
|
|
251
|
+
# self` directly inside one attaches to the factory's class.
|
|
252
|
+
def owner_path(nesting)
|
|
253
|
+
return nil if nesting.empty?
|
|
254
|
+
|
|
255
|
+
parts = []
|
|
256
|
+
factory_path = nil
|
|
257
|
+
nesting.each do |scope|
|
|
258
|
+
if scope.sclass?
|
|
259
|
+
# `self` is the innermost named scope: the factory's class inside a
|
|
260
|
+
# factory block (its block is class_eval'd), the attached class or
|
|
261
|
+
# module otherwise.
|
|
262
|
+
if factory_path
|
|
263
|
+
# factory_path carries the full prefix already: replace, never
|
|
264
|
+
# append, or the enclosing scope doubles (#395).
|
|
265
|
+
parts = ["#{factory_path}.singleton_class"]
|
|
266
|
+
elsif parts.any?
|
|
267
|
+
parts[-1] = "#{parts.last}.singleton_class"
|
|
268
|
+
end
|
|
269
|
+
factory_path = nil
|
|
270
|
+
elsif scope.factory?
|
|
271
|
+
factory_path = (parts + [scope.path.delete_prefix("::")]).join("::")
|
|
272
|
+
else
|
|
273
|
+
parts.clear if scope.path.start_with?("::")
|
|
274
|
+
parts << scope.path.delete_prefix("::")
|
|
275
|
+
factory_path = nil
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
parts.empty? ? nil : parts.join("::")
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# Every Coverage.result read goes through here. With the methods report
|
|
6
|
+
# on, the C call hashes each method owner and trusts the return type, so
|
|
7
|
+
# a loaded class whose `hash` does not return an Integer (forem's
|
|
8
|
+
# ReactionCategory, #564) makes every read raise TypeError - a "please
|
|
9
|
+
# report it" crash for what is a bug in the application's code. Refuse
|
|
10
|
+
# instead. The handler runs no app code (an ObjectSpace scan would call
|
|
11
|
+
# #hash/#name on hostile classes) and the error's backtrace names no
|
|
12
|
+
# owning class, so the message stays generic rather than accusing one.
|
|
13
|
+
module CoverageReads
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def result(stop:, clear:)
|
|
17
|
+
Coverage.result(stop:, clear:)
|
|
18
|
+
rescue TypeError => e
|
|
19
|
+
raise Error, diagnostic(e)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def diagnostic(error)
|
|
23
|
+
"Coverage.result raised TypeError (#{error.message}): Coverage's method report hashes every " \
|
|
24
|
+
"method owner and trusts the result, and a loaded class whose #hash does not return an " \
|
|
25
|
+
"Integer (Ruby's #hash contract) breaks it. This is a bug in the application's code, " \
|
|
26
|
+
"not in saboteur."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# The warning shown before a run on a tree with uncommitted changes
|
|
6
|
+
# (#309, #316). Mutants run your code and can change the tree, so the
|
|
7
|
+
# warning stays; it splits the raw count so a tree full of notes or trial
|
|
8
|
+
# setup files does not read like application edits about to be mutated.
|
|
9
|
+
#
|
|
10
|
+
# Each change lands in exactly one group, so the groups add up to the
|
|
11
|
+
# count: saboteur setup files first (the config file the run reads, a
|
|
12
|
+
# trial Gemfile and its lock), then the mutation scope (a file the
|
|
13
|
+
# configured include and exclude select, the way Config#included_files
|
|
14
|
+
# does), then the rest. A path the patterns match also counts, so a
|
|
15
|
+
# deleted source is still inside the scope. The scope is the configured
|
|
16
|
+
# one: --since, --subject and source operands narrow a run further, after
|
|
17
|
+
# this warning.
|
|
18
|
+
class DirtyTreeWarning
|
|
19
|
+
SHOWN_PATHS = 5
|
|
20
|
+
MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
21
|
+
ADVICE = "Mutants run your code and could alter or delete files; " \
|
|
22
|
+
"run on a clean, committed tree or a CI checkout."
|
|
23
|
+
|
|
24
|
+
# The warning text for the run's working tree, or nil on a clean or
|
|
25
|
+
# unknown tree. The root is physical (realpath), like the paths
|
|
26
|
+
# Config#included_files returns.
|
|
27
|
+
def self.for(config, config_path:, gemfile: ENV.fetch("BUNDLE_GEMFILE", nil))
|
|
28
|
+
state = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
29
|
+
root = File.realpath(config.root)
|
|
30
|
+
new(state, included: relative(config.included_files, root), include: config["include"],
|
|
31
|
+
exclude: config["exclude"], root: root,
|
|
32
|
+
setup_files: setup_files(root, config_path: config_path, gemfile: gemfile)).to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Files a trial of saboteur adds, relative to the root: the config file
|
|
36
|
+
# the run reads and, when BUNDLE_GEMFILE names a Gemfile other than the
|
|
37
|
+
# root's own, that Gemfile and its lock. Each file is compared by its
|
|
38
|
+
# physical path, so a /var spelling of a /private/var root still
|
|
39
|
+
# matches (#503).
|
|
40
|
+
def self.setup_files(root, config_path:, gemfile:)
|
|
41
|
+
files = [physical(File.expand_path(config_path || Config::FILE, root))]
|
|
42
|
+
trial = gemfile && physical(File.expand_path(gemfile, root))
|
|
43
|
+
files.push(trial, lockfile_for(trial)) if trial && trial != File.join(root, "Gemfile")
|
|
44
|
+
relative(files, root)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Bundler's own rule, the same one preflight's execution inputs check
|
|
48
|
+
# against Bundler.default_lockfile: a gems.rb trial locks to gems.locked
|
|
49
|
+
# next to it; every other name gets ".lock" appended.
|
|
50
|
+
def self.lockfile_for(gemfile)
|
|
51
|
+
return File.join(File.dirname(gemfile), "gems.locked") if File.basename(gemfile) == "gems.rb"
|
|
52
|
+
|
|
53
|
+
"#{gemfile}.lock"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# git status paths are relative to the root, so the patterns are too:
|
|
57
|
+
# Dir.glob accepts a leading ./ or an absolute path under the root and
|
|
58
|
+
# File.fnmatch? does not (#503). A pattern outside the root stays
|
|
59
|
+
# absolute and matches no git path.
|
|
60
|
+
def self.relative_patterns(patterns, root)
|
|
61
|
+
patterns.map { |pattern| File.expand_path(pattern, root).delete_prefix(File.join(root, "")) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The path with its directory resolved, so a file that does not exist
|
|
65
|
+
# yet still gets the physical spelling of where it would be.
|
|
66
|
+
def self.physical(path)
|
|
67
|
+
File.join(File.realpath(File.dirname(path)), File.basename(path))
|
|
68
|
+
rescue SystemCallError
|
|
69
|
+
path
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A file outside the root keeps its absolute path, which no
|
|
73
|
+
# root-relative git path can equal.
|
|
74
|
+
def self.relative(files, root)
|
|
75
|
+
prefix = File.join(root, "")
|
|
76
|
+
files.map { |file| file.delete_prefix(prefix) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private_class_method :physical, :relative
|
|
80
|
+
|
|
81
|
+
def initialize(state, included:, include:, exclude:, setup_files:, root:)
|
|
82
|
+
@state = state
|
|
83
|
+
@included = included.to_set
|
|
84
|
+
@include = self.class.relative_patterns(include, root)
|
|
85
|
+
@exclude = self.class.relative_patterns(exclude, root)
|
|
86
|
+
@setup_files = setup_files
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_s
|
|
90
|
+
return unless @state.dirty
|
|
91
|
+
|
|
92
|
+
setup, rest = @state.changes.partition { |change| @setup_files.include?(change.path) }
|
|
93
|
+
scope, outside = rest.partition { |change| in_scope?(change.path) }
|
|
94
|
+
[summary(setup, scope, outside), named("inside the mutation scope", scope), named("setup files", setup),
|
|
95
|
+
" #{ADVICE}"].compact.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def summary(setup, scope, outside)
|
|
101
|
+
groups = [scope.empty? ? "none inside the mutation scope" : counted("inside the mutation scope", scope),
|
|
102
|
+
counted("outside it", outside)]
|
|
103
|
+
groups << counted("saboteur setup file(s)", setup) unless setup.empty?
|
|
104
|
+
"saboteur: warning: #{@state.changed_files} uncommitted change(s) in the working tree: #{groups.join(', ')}."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def counted(label, changes)
|
|
108
|
+
tracked = changes.count(&:tracked)
|
|
109
|
+
"#{changes.size} #{label} (#{tracked} tracked, #{changes.size - tracked} untracked)"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def named(label, changes)
|
|
113
|
+
return if changes.empty?
|
|
114
|
+
|
|
115
|
+
paths = changes.first(SHOWN_PATHS).map(&:path).join(", ")
|
|
116
|
+
more = changes.size - SHOWN_PATHS
|
|
117
|
+
" #{label}: #{paths}#{" and #{more} more" if more.positive?}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def in_scope?(path)
|
|
121
|
+
@included.include?(path) || (path.end_with?(".rb") && matches?(@include, path) && !matches?(@exclude, path))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def matches?(patterns, path)
|
|
125
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, path, MATCH_FLAGS) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -98,6 +98,15 @@ module Saboteur
|
|
|
98
98
|
else
|
|
99
99
|
@out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
|
|
100
100
|
"children would run with the hazard guard and canary only"
|
|
101
|
+
if RUBY_PLATFORM.match?(/darwin/i) && @config["sandbox"] == "seatbelt"
|
|
102
|
+
@out.puts %(Seatbelt (opt-in, #436): probe #{Execution::Seatbelt.probe} - capability probe only, ) \
|
|
103
|
+
"not verified isolation"
|
|
104
|
+
elsif RUBY_PLATFORM.match?(/darwin/i)
|
|
105
|
+
@out.puts "macOS: mutants run unsandboxed (Landlock is Linux-only) - a container contains " \
|
|
106
|
+
"filesystem writes outside the project"
|
|
107
|
+
@out.puts "Network: never restricted on any platform, and a plain container keeps outbound " \
|
|
108
|
+
"access - deny or stub the network when a mutant could reach real APIs"
|
|
109
|
+
end
|
|
101
110
|
end
|
|
102
111
|
end
|
|
103
112
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require_relative "../text"
|
|
7
|
+
require_relative "../frameworks/sqlite_memory_database"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
9
10
|
class Engine
|
|
@@ -52,7 +53,7 @@ module Saboteur
|
|
|
52
53
|
announce_first_pass_cost(number, started)
|
|
53
54
|
unless data["passed"] && data["failed"].empty?
|
|
54
55
|
refuse(number,
|
|
55
|
-
"the suite failed: #{Array(data['failed']).join(', ')}")
|
|
56
|
+
with_hint("the suite failed: #{Array(data['failed']).join(', ')}", data))
|
|
56
57
|
end
|
|
57
58
|
executed = data["executed"]
|
|
58
59
|
refuse(number, "executed tests do not match the loaded suite") unless
|
|
@@ -85,16 +86,24 @@ module Saboteur
|
|
|
85
86
|
end
|
|
86
87
|
|
|
87
88
|
# Once the first pass is timed, the total cost stops being a guess:
|
|
88
|
-
# the remaining full passes cost roughly the same each.
|
|
89
|
+
# the remaining full passes cost roughly the same each. full_passes
|
|
90
|
+
# includes the cold-cache coverage build (#260), so name what actually
|
|
91
|
+
# remains: with baseline_passes 1 the one remaining pass is the coverage
|
|
92
|
+
# build, not another baseline check (#465).
|
|
89
93
|
def announce_first_pass_cost(number, started)
|
|
90
94
|
return unless number == 1
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
coverage_pending = @full_passes > @config["baseline_passes"]
|
|
97
|
+
remaining = @full_passes - number - (coverage_pending ? 1 : 0)
|
|
98
|
+
return if remaining.zero? && !coverage_pending
|
|
94
99
|
|
|
95
100
|
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
work = []
|
|
102
|
+
work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
|
|
103
|
+
work << "the coverage map build" if coverage_pending
|
|
104
|
+
total = remaining + (coverage_pending ? 1 : 0)
|
|
105
|
+
@diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
|
|
106
|
+
"#{work.join(' and ')} at that rate")
|
|
98
107
|
end
|
|
99
108
|
|
|
100
109
|
# An admitted pass still gets only the time left in the run budget:
|
|
@@ -117,6 +126,24 @@ module Saboteur
|
|
|
117
126
|
"phase timeout)"
|
|
118
127
|
end
|
|
119
128
|
|
|
129
|
+
# A containment limit stopping a healthy suite means the limit, not
|
|
130
|
+
# the suite: name the knob and its current value, like the sqlite
|
|
131
|
+
# :memory: hint (#387), so the user is not sent debugging a green
|
|
132
|
+
# suite (#459).
|
|
133
|
+
def stopped_reason(result)
|
|
134
|
+
reason = "the suite stopped: #{result.stopped_by} (#{result.detail})"
|
|
135
|
+
case result.stopped_by
|
|
136
|
+
when :processes
|
|
137
|
+
"#{reason}. If this suite forks heavily by design, raise the limit with " \
|
|
138
|
+
"c.max_processes (currently #{@config['max_processes']})"
|
|
139
|
+
when :memory
|
|
140
|
+
"#{reason}. If this suite legitimately needs more memory, raise the limit with " \
|
|
141
|
+
"c.max_memory_mb (currently #{@config['max_memory_mb']} MB)"
|
|
142
|
+
else
|
|
143
|
+
reason
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
120
147
|
def check(number)
|
|
121
148
|
Execution::ForkReadiness.prepare_heap
|
|
122
149
|
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
|
|
@@ -126,14 +153,14 @@ module Saboteur
|
|
|
126
153
|
record(writer, number)
|
|
127
154
|
end
|
|
128
155
|
refuse_budget_stop(number, admitted) if budget_stopped?(result, admitted)
|
|
129
|
-
refuse(number,
|
|
156
|
+
refuse(number, stopped_reason(result)) if result.stopped_by
|
|
130
157
|
# Before the payload checks: a child killed by a signal (the OOM
|
|
131
158
|
# killer, say) writes nothing, and the signal is the explanation. A
|
|
132
159
|
# child the supervisor itself killed after it had reported is not a
|
|
133
160
|
# failure: its payload is checked below.
|
|
134
161
|
refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
|
|
135
162
|
refuse(number, "the child did not report a result") if result.payload.empty?
|
|
136
|
-
refuse(number,
|
|
163
|
+
refuse(number, leftover_message(result), excerpt: false) if result.leftover_pids.any?
|
|
137
164
|
parse_result(number, result.payload)
|
|
138
165
|
end
|
|
139
166
|
|
|
@@ -151,7 +178,7 @@ module Saboteur
|
|
|
151
178
|
def parse_result(number, payload)
|
|
152
179
|
data = JSON.parse(payload)
|
|
153
180
|
refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
|
|
154
|
-
refuse(number, data["error"]) if data["error"]
|
|
181
|
+
refuse(number, with_hint(data["error"], data)) if data["error"]
|
|
155
182
|
refuse(number, "the child reported invalid test results") unless valid_result?(data)
|
|
156
183
|
data
|
|
157
184
|
rescue JSON::ParserError
|
|
@@ -172,21 +199,42 @@ module Saboteur
|
|
|
172
199
|
@hooks.run(:after_fork)
|
|
173
200
|
Kernel.srand(@config["seed"])
|
|
174
201
|
result = @integration.run_baseline(seed: @config["seed"])
|
|
175
|
-
|
|
202
|
+
payload = { passed: result.passed, failed: result.failed_ids, executed: result.executed_ids }
|
|
203
|
+
payload[:hint] = Frameworks::SqliteMemoryDatabase.fork_loss_hint unless result.passed
|
|
204
|
+
writer.write(JSON.generate(payload))
|
|
176
205
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
177
|
-
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"
|
|
206
|
+
writer.write(JSON.generate(error: "#{e.class}: #{e.message}",
|
|
207
|
+
hint: Frameworks::SqliteMemoryDatabase.fork_loss_hint))
|
|
178
208
|
ensure
|
|
179
209
|
writer.close
|
|
180
210
|
end
|
|
181
211
|
|
|
212
|
+
# A child that recognises its own failure shape (an empty sqlite
|
|
213
|
+
# :memory: database after the fork, #387) says how to fix it.
|
|
214
|
+
def with_hint(reason, data)
|
|
215
|
+
data["hint"] ? "#{reason}. #{data['hint']}" : reason
|
|
216
|
+
end
|
|
217
|
+
|
|
182
218
|
def log_path(number)
|
|
183
219
|
File.join(@config.path("cache_dir"), "baseline-#{number}.log")
|
|
184
220
|
end
|
|
185
221
|
|
|
186
|
-
|
|
222
|
+
# Leftover children are a pass-with-leaks verdict, not a suite
|
|
223
|
+
# failure: the message names the leaked processes (the supervisor's
|
|
224
|
+
# pre-kill notes) and skips the log excerpt, which holds only RSpec
|
|
225
|
+
# output and can never show them (#486).
|
|
226
|
+
def leftover_message(result)
|
|
227
|
+
notes = result.leftover_notes
|
|
228
|
+
detail = notes.empty? ? result.leftover_pids.join(", ") : notes.join(", ")
|
|
229
|
+
count = result.leftover_pids.size
|
|
230
|
+
"the suite passed but left #{count} child #{count == 1 ? 'process' : 'processes'} behind: #{detail}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def refuse(number, reason, excerpt: true)
|
|
187
234
|
raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
|
|
188
|
-
"#{failure_excerpt(number)}See the full log at #{log_path(number)}.
|
|
189
|
-
"setup, then run saboteur again with the same seed.
|
|
235
|
+
"#{failure_excerpt(number) if excerpt}See the full log at #{log_path(number)}. " \
|
|
236
|
+
"Fix the suite or its setup, then run saboteur again with the same seed. " \
|
|
237
|
+
"No mutants were started."
|
|
190
238
|
end
|
|
191
239
|
|
|
192
240
|
# The first lines of the failure, inline, so the cause is readable
|