saboteur 0.6.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 +1024 -0
- data/README.md +199 -952
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +80 -4
- data/lib/saboteur/cli/help_options.rb +17 -0
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +40 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- 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 +136 -98
- data/lib/saboteur/config.rb +163 -15
- 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/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +49 -1
- data/lib/saboteur/engine/baseline_guard.rb +136 -14
- data/lib/saboteur/engine/boot.rb +16 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +28 -1
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +79 -15
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +41 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +134 -8
- data/lib/saboteur/engine/early_stop_recording.rb +47 -0
- 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 +67 -9
- data/lib/saboteur/engine/run_budget.rb +90 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +56 -11
- data/lib/saboteur/engine/scheduler.rb +222 -20
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +373 -19
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +438 -25
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +202 -158
- 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/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +225 -24
- data/lib/saboteur/execution/sandbox.rb +21 -3
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +45 -13
- data/lib/saboteur/execution/worker_pool.rb +110 -8
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +108 -13
- data/lib/saboteur/ignore_list.rb +40 -7
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +175 -9
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +44 -14
- 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 +129 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +193 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +6 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +7 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- 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 +122 -13
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +47 -15
- data/lib/saboteur/reporter/agent_incomplete.rb +38 -2
- 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/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +40 -7
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +74 -17
- data/lib/saboteur/result.rb +38 -7
- data/lib/saboteur/selection/coverage_map.rb +107 -11
- 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 +105 -13
- 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/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +53 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +29 -1
- metadata +50 -2
|
@@ -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,154 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "pathname"
|
|
8
|
+
|
|
9
|
+
module Saboteur
|
|
10
|
+
# Last-resort crash reporting, shared by the CLI's StandardError rescue
|
|
11
|
+
# and the exe-level wrapper: one bounded line on stderr, the full
|
|
12
|
+
# backtrace in a log file the line names, the backtrace on stderr with
|
|
13
|
+
# --verbose, and the same failure as agent JSON when an agent listens.
|
|
14
|
+
# Always returns EXIT_TOOL.
|
|
15
|
+
module CrashReport
|
|
16
|
+
ISSUES_URL = "https://github.com/vamsipavanmahesh/saboteur/issues"
|
|
17
|
+
|
|
18
|
+
# Same value as CLI::EXIT_TOOL, kept local so the reporter still works
|
|
19
|
+
# when the CLI itself never finished loading.
|
|
20
|
+
EXIT_TOOL = 3
|
|
21
|
+
|
|
22
|
+
# The crash log's file name under the run's cache dir; the runner reads
|
|
23
|
+
# it back to explain a nested run's crash in the failure detail (#296).
|
|
24
|
+
LOG_NAME = "internal-error.log"
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def call(error, verbose:, agent:, err:, out:, log_path: nil)
|
|
29
|
+
summary = "internal error (#{error.class}: #{one_line(error.message)})"
|
|
30
|
+
line = "saboteur: #{summary} - please report it: #{ISSUES_URL}"
|
|
31
|
+
written = write_log(log_path, error)
|
|
32
|
+
line = "#{line}; full backtrace: #{display_path(written)}" if written
|
|
33
|
+
err.puts(line)
|
|
34
|
+
err.puts(full_text(error)) if verbose
|
|
35
|
+
out.print(agent_json(summary)) if agent
|
|
36
|
+
EXIT_TOOL
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A crash message can span lines or carry bytes that are invalid in
|
|
40
|
+
# UTF-8 (a LANG=C failure, say); the one-line diagnostic must survive
|
|
41
|
+
# both, and stay one bounded line.
|
|
42
|
+
def one_line(message)
|
|
43
|
+
text = scrub(message).gsub(/\s+/, " ").strip
|
|
44
|
+
text.length > 160 ? "#{text[0...160]}..." : text
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Class, full message and backtrace, scrubbed so locale-invalid bytes
|
|
48
|
+
# cannot corrupt the log or the terminal.
|
|
49
|
+
def full_text(error)
|
|
50
|
+
(["#{error.class}: #{scrub(error.message)}"] + Array(error.backtrace).map { |frame| scrub(frame) }).join("\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Writes the log next to the other run state; returns the written path,
|
|
54
|
+
# or nil when the log cannot be written - the crash itself must still
|
|
55
|
+
# be reported, so an unwritable directory is not a second failure.
|
|
56
|
+
# Reporter::Agent.failure's payload, reproduced minimally for crashes
|
|
57
|
+
# before the CLI's reporter files load: same schema and pretty rendering;
|
|
58
|
+
# the summary is already normalized, so no message cap is needed here.
|
|
59
|
+
def agent_json(summary)
|
|
60
|
+
payload =
|
|
61
|
+
if defined?(Reporter::Agent)
|
|
62
|
+
Reporter::Agent.failure(summary, EXIT_TOOL)
|
|
63
|
+
else
|
|
64
|
+
{ version: 1, status: "error", verdict: "tool_failure", exitCode: EXIT_TOOL,
|
|
65
|
+
message: summary, next: { help: "saboteur help run" } }
|
|
66
|
+
end
|
|
67
|
+
"#{JSON.pretty_generate(payload)}\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_log(log_path, error)
|
|
71
|
+
return nil unless log_path
|
|
72
|
+
|
|
73
|
+
# The primary path is user-configured but still predictable: without
|
|
74
|
+
# O_NOFOLLOW a planted symlink would redirect the truncate/chmod, and
|
|
75
|
+
# without O_NONBLOCK a planted FIFO would block the open. Where the
|
|
76
|
+
# platform lacks either, only the EXCL-created temp fallback is safe
|
|
77
|
+
# (#296).
|
|
78
|
+
if safe_log_io?
|
|
79
|
+
begin
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
81
|
+
# Private like the temp fallback: the runner only reads logs with
|
|
82
|
+
# no group/other bits, from the same no-follow fd (#296).
|
|
83
|
+
flags = File::CREAT | File::WRONLY | File::TRUNC | nofollow_flag | nonblock_flag
|
|
84
|
+
File.open(log_path, flags, 0o600) do |file|
|
|
85
|
+
file.chmod(0o600)
|
|
86
|
+
file.write("#{full_text(error)}\n")
|
|
87
|
+
end
|
|
88
|
+
return log_path
|
|
89
|
+
rescue SystemCallError, IOError
|
|
90
|
+
# Fall through to the temp fallback.
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
write_tmp_log(error)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The fallback is computed lazily, only once the primary write failed:
|
|
97
|
+
# an eager tmp_log_path call could itself raise (Dir.tmpdir finds no
|
|
98
|
+
# usable dir) and mask the crash it was meant to log (#296).
|
|
99
|
+
#
|
|
100
|
+
# The fallback path is predictable and lives in a shared directory, so
|
|
101
|
+
# it is created privately: CREAT|EXCL|WRONLY|NOFOLLOW never follows or
|
|
102
|
+
# overwrites a pre-existing file or symlink, and the mode pins the log
|
|
103
|
+
# to 0600. A taken or hostile path means no log - the crash report
|
|
104
|
+
# itself must still go out, so every failure here degrades to nil.
|
|
105
|
+
def write_tmp_log(error)
|
|
106
|
+
File.open(tmp_log_path, File::CREAT | File::EXCL | File::WRONLY | nofollow_flag, 0o600) do |file|
|
|
107
|
+
file.write("#{full_text(error)}\n")
|
|
108
|
+
end
|
|
109
|
+
tmp_log_path
|
|
110
|
+
rescue SystemCallError, IOError, ArgumentError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# O_NOFOLLOW where the platform has it (every CI target does).
|
|
115
|
+
def nofollow_flag
|
|
116
|
+
File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# O_NONBLOCK where the platform has it (every CI target does).
|
|
120
|
+
def nonblock_flag
|
|
121
|
+
File.const_defined?(:NONBLOCK) ? File::NONBLOCK : 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The private crash-log write/read needs both flags: without O_NOFOLLOW
|
|
125
|
+
# a planted symlink redirects the open, without O_NONBLOCK a planted
|
|
126
|
+
# FIFO blocks it. Where either is missing, the log is omitted rather
|
|
127
|
+
# than opened unsafely (#296).
|
|
128
|
+
def safe_log_io?
|
|
129
|
+
File.const_defined?(:NOFOLLOW) && File.const_defined?(:NONBLOCK)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Where the log lands when a configured cache dir is not writable - a
|
|
133
|
+
# sandboxed child holds the project read-only, but the system temp dir is
|
|
134
|
+
# granted (#296). Only a configured log path falls back: cache_dir = nil
|
|
135
|
+
# still means no log at all. Keyed by pid so parallel children never
|
|
136
|
+
# share a file; the runner reads the same path back in the same process.
|
|
137
|
+
def tmp_log_path(pid: Process.pid)
|
|
138
|
+
File.join(Dir.tmpdir, "saboteur-#{pid}-#{LOG_NAME}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The message names the log; relative to the working directory when it
|
|
142
|
+
# sits inside it, so the line stays short.
|
|
143
|
+
def display_path(path)
|
|
144
|
+
relative = Pathname(path).relative_path_from(Pathname(Dir.pwd)).to_s
|
|
145
|
+
relative.start_with?("..") ? path : relative
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
path
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def scrub(string)
|
|
151
|
+
string.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/saboteur/custom_map.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "text"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
# Explicit source-file -> spec-file mapping from config/saboteur_custom_map.rb:
|
|
6
8
|
#
|
|
@@ -109,7 +111,7 @@ module Saboteur
|
|
|
109
111
|
def duplicate_keys(path)
|
|
110
112
|
seen = Hash.new(0)
|
|
111
113
|
File.readlines(path).each do |line|
|
|
112
|
-
seen[::Regexp.last_match(1)] += 1 if line =~ /^\s*"(.+?)"\s*=>/
|
|
114
|
+
seen[::Regexp.last_match(1)] += 1 if Text.utf8(line) =~ /^\s*"(.+?)"\s*=>/
|
|
113
115
|
end
|
|
114
116
|
seen.select { |_, count| count > 1 }.keys
|
|
115
117
|
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
|