saboteur 0.7.0 → 0.8.1
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 +772 -0
- data/README.md +221 -1072
- data/lib/saboteur/attribute_finder.rb +113 -0
- 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 +47 -3
- data/lib/saboteur/cli/run_budget_options.rb +3 -1
- 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 +35 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +74 -55
- data/lib/saboteur/config.rb +102 -6
- data/lib/saboteur/config_file.rb +19 -7
- 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/custom_map.rb +12 -1
- data/lib/saboteur/dirty_tree_warning.rb +130 -0
- data/lib/saboteur/doctor.rb +13 -0
- data/lib/saboteur/engine/baseline_guard.rb +135 -21
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +20 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +58 -14
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +19 -1
- 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 +37 -0
- data/lib/saboteur/engine/load_coverage.rb +63 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +115 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +32 -2
- data/lib/saboteur/engine/sandbox_policy.rb +59 -3
- data/lib/saboteur/engine/scheduler.rb +131 -17
- data/lib/saboteur/engine/selection_announcement.rb +59 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +380 -21
- data/lib/saboteur/engine/test_guess.rb +407 -27
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +103 -129
- data/lib/saboteur/execution/external_runner.rb +489 -0
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/marker_sweep.rb +80 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +19 -3
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +75 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +25 -6
- data/lib/saboteur/execution/seatbelt.rb +233 -0
- data/lib/saboteur/execution/supervisor.rb +29 -11
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -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 +223 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +28 -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 +189 -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 +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -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 +125 -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 +31 -5
- 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 +56 -8
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +120 -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 +14 -0
- metadata +39 -16
data/lib/saboteur/cli.rb
CHANGED
|
@@ -11,17 +11,27 @@ require_relative "baseline/command"
|
|
|
11
11
|
require_relative "cli/exit_status"
|
|
12
12
|
require_relative "preview"
|
|
13
13
|
require_relative "cli/run_reporting"
|
|
14
|
+
require_relative "cli/survivors_command"
|
|
15
|
+
require_relative "cli/source_set"
|
|
14
16
|
require_relative "cli/run_budget_options"
|
|
17
|
+
require_relative "cli/shard_options"
|
|
18
|
+
require_relative "cli/selection_options"
|
|
15
19
|
require_relative "cli/presets"
|
|
16
20
|
require_relative "cli/help_options"
|
|
21
|
+
require_relative "cli/history_command"
|
|
17
22
|
|
|
18
23
|
module Saboteur
|
|
19
24
|
class CLI
|
|
20
25
|
include Baseline::Command
|
|
21
26
|
include ExitStatus
|
|
22
27
|
include RunReporting
|
|
28
|
+
include SurvivorsCommand
|
|
29
|
+
include SourceSet
|
|
23
30
|
include RunBudgetOptions
|
|
31
|
+
include ShardOptions
|
|
32
|
+
include SelectionOptions
|
|
24
33
|
include Presets
|
|
34
|
+
include HistoryCommand
|
|
25
35
|
include HelpOptions
|
|
26
36
|
|
|
27
37
|
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
|
|
@@ -58,11 +68,13 @@ module Saboteur
|
|
|
58
68
|
case command
|
|
59
69
|
when "run" then run_command
|
|
60
70
|
when "baseline" then baseline
|
|
61
|
-
when "
|
|
71
|
+
when "survivors" then survivors_command
|
|
72
|
+
when "doctor" then doctor_command
|
|
73
|
+
when "badge", "trend" then history_entry(command)
|
|
62
74
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
63
75
|
when "help", "--help", "-h" then show_help(@argv.shift)
|
|
64
76
|
when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
|
|
65
|
-
else
|
|
77
|
+
else preset_or_usage_error(command)
|
|
66
78
|
end
|
|
67
79
|
rescue OptionParser::ParseError => e
|
|
68
80
|
usage_error(e.message)
|
|
@@ -98,13 +110,13 @@ module Saboteur
|
|
|
98
110
|
|
|
99
111
|
private
|
|
100
112
|
|
|
101
|
-
def run_command
|
|
102
|
-
help_requested? ? show_help("run") : run
|
|
103
|
-
end
|
|
113
|
+
def run_command = help_requested? ? show_help("run") : run
|
|
104
114
|
|
|
105
|
-
def help_requested?
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
def doctor_command = help_requested? ? show_help("doctor") : doctor
|
|
116
|
+
|
|
117
|
+
def history_entry(command) = help_requested? ? show_help(command) : history_command(command)
|
|
118
|
+
|
|
119
|
+
def help_requested? = @argv.intersect?(%w[--help -h])
|
|
108
120
|
|
|
109
121
|
def show_help(command_name)
|
|
110
122
|
help = Help.new(option_lines: option_lines)
|
|
@@ -121,9 +133,7 @@ module Saboteur
|
|
|
121
133
|
agent_failure(message || USAGE, EXIT_USAGE)
|
|
122
134
|
end
|
|
123
135
|
|
|
124
|
-
def agent?
|
|
125
|
-
@format == "agent"
|
|
126
|
-
end
|
|
136
|
+
def agent? = @format == "agent"
|
|
127
137
|
|
|
128
138
|
def agent_requested? = self.class.agent_requested?(@argv)
|
|
129
139
|
|
|
@@ -144,6 +154,7 @@ module Saboteur
|
|
|
144
154
|
|
|
145
155
|
return preview if options[:list_operators] || options[:dry_run]
|
|
146
156
|
|
|
157
|
+
refuse_empty_source_set
|
|
147
158
|
execute_run
|
|
148
159
|
rescue ConfigError => e
|
|
149
160
|
@err.puts("saboteur: #{e.message}")
|
|
@@ -155,8 +166,12 @@ module Saboteur
|
|
|
155
166
|
|
|
156
167
|
# Mutants run your code, and a mutant of code that writes or deletes files
|
|
157
168
|
# can change the tree. Say so up front when uncommitted work is at stake.
|
|
169
|
+
# The scope the warning names is the configured one: positional source
|
|
170
|
+
# operands narrow the run after the warning, so the include/exclude they
|
|
171
|
+
# imply stay out of its config (#309, #503 review).
|
|
158
172
|
def warn_dirty_tree
|
|
159
|
-
|
|
173
|
+
warning = @sources ? Config.load(path: options[:config], overrides: overrides.except(:include, :exclude)) : config
|
|
174
|
+
DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
|
|
160
175
|
end
|
|
161
176
|
|
|
162
177
|
# Contradictory selection flags are a usage error before any file or
|
|
@@ -165,30 +180,46 @@ module Saboteur
|
|
|
165
180
|
# --test wins there, so generated commands stay valid over any config.
|
|
166
181
|
def reject_test_selection_conflicts
|
|
167
182
|
raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
|
|
183
|
+
|
|
184
|
+
reject_test_command_conflicts
|
|
168
185
|
raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
|
|
169
186
|
return unless options[:guess_tests] && options[:tests]&.any?
|
|
170
187
|
|
|
171
188
|
raise UsageError, "--guess-tests cannot be combined with --test"
|
|
172
189
|
end
|
|
173
190
|
|
|
191
|
+
# --test-command replaces the whole in-process test machinery: test
|
|
192
|
+
# selection, coverage, parallelism and the sandbox do not apply.
|
|
193
|
+
def reject_test_command_conflicts
|
|
194
|
+
return unless options[:test_command]
|
|
195
|
+
|
|
196
|
+
# options[:tests] is nil or a non-empty list, so truthiness is the check.
|
|
197
|
+
raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
|
|
198
|
+
raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
|
|
199
|
+
end
|
|
200
|
+
|
|
174
201
|
# Usage errors found before the target boots.
|
|
175
202
|
def validate_run_options
|
|
176
|
-
|
|
203
|
+
reject_format_conflicts
|
|
204
|
+
validate_shard_options
|
|
177
205
|
Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
|
|
178
206
|
end
|
|
179
207
|
|
|
180
|
-
# Agent mode
|
|
181
|
-
def
|
|
182
|
-
|
|
208
|
+
# Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
|
|
209
|
+
def reject_format_conflicts
|
|
210
|
+
if agent? && (config["threshold"] || gates.any?)
|
|
211
|
+
raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
|
|
212
|
+
"cannot be combined with --agent"
|
|
213
|
+
end
|
|
214
|
+
return unless @format == "json" && !options[:dry_run]
|
|
183
215
|
|
|
184
|
-
raise UsageError, "
|
|
216
|
+
raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
|
|
185
217
|
end
|
|
186
218
|
|
|
187
219
|
# One line naming the reports written, paths relative to the root when
|
|
188
220
|
# they sit under it (a blank line before it, as the old JSON line had).
|
|
189
221
|
def announce_reports(reports)
|
|
190
|
-
written = reports.select { |path, _label| path }
|
|
191
|
-
return if written.empty?
|
|
222
|
+
return if (written = reports.select { |path, _label| path }).empty?
|
|
192
223
|
|
|
193
224
|
root = File.join(File.realpath(config.root), "")
|
|
194
225
|
names = written.map do |path, label|
|
|
@@ -233,10 +264,14 @@ module Saboteur
|
|
|
233
264
|
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
|
|
234
265
|
sources: @sources || [],
|
|
235
266
|
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
236
|
-
incremental: config["incremental"], settings: agent_settings
|
|
267
|
+
incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings
|
|
237
268
|
)
|
|
238
269
|
end
|
|
239
270
|
|
|
271
|
+
# The settings keys typed on the command line: a typed empty operator
|
|
272
|
+
# list may clear a config value, so the generated commands keep it.
|
|
273
|
+
def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
|
|
274
|
+
|
|
240
275
|
# A whole-suite run records its scope as --all, so the generated rerun
|
|
241
276
|
# and replay commands do not default back to inference (which can exit
|
|
242
277
|
# 2 in a project with a spec-less source file).
|
|
@@ -271,17 +306,15 @@ module Saboteur
|
|
|
271
306
|
# replace the configured include and exclude patterns. Each must exist,
|
|
272
307
|
# so a typo is an error and not a run over zero subjects.
|
|
273
308
|
def use_explicit_sources
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
end
|
|
309
|
+
Selection::SourceScope.verify_no_specs!(@argv)
|
|
310
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
311
|
+
# --test may stand alone: the configured include (or the --since
|
|
312
|
+
# diff) names the sources. The files must still exist.
|
|
313
|
+
return if @argv.empty?
|
|
280
314
|
|
|
281
315
|
@sources = @argv.dup
|
|
282
316
|
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
283
317
|
options[:exclude] = []
|
|
284
|
-
options[:tests]&.each { |test| require_existing_test(test) }
|
|
285
318
|
end
|
|
286
319
|
|
|
287
320
|
def source_pattern(source)
|
|
@@ -303,12 +336,10 @@ module Saboteur
|
|
|
303
336
|
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
304
337
|
end
|
|
305
338
|
|
|
306
|
-
def overrides
|
|
307
|
-
options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
308
|
-
end
|
|
339
|
+
def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
309
340
|
|
|
310
341
|
def preview
|
|
311
|
-
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
|
|
342
|
+
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
|
|
312
343
|
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
313
344
|
|
|
314
345
|
if options[:list_operators]
|
|
@@ -316,24 +347,10 @@ module Saboteur
|
|
|
316
347
|
return EXIT_OK
|
|
317
348
|
end
|
|
318
349
|
|
|
319
|
-
Preview.new(config, out: @out, err: @err).call
|
|
350
|
+
Preview.new(config, out: @out, err: @err, format: @format).call
|
|
320
351
|
EXIT_OK
|
|
321
352
|
end
|
|
322
353
|
|
|
323
|
-
def define_selection_options(opts)
|
|
324
|
-
opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
|
|
325
|
-
set(:operators, v)
|
|
326
|
-
end
|
|
327
|
-
opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
|
|
328
|
-
set(:exclude_operators, v)
|
|
329
|
-
end
|
|
330
|
-
opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
|
|
331
|
-
opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
|
|
332
|
-
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
333
|
-
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
334
|
-
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
335
|
-
end
|
|
336
|
-
|
|
337
354
|
def define_execution_options(opts)
|
|
338
355
|
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
339
356
|
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
@@ -352,6 +369,10 @@ module Saboteur
|
|
|
352
369
|
end
|
|
353
370
|
opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
|
|
354
371
|
"mutated files") { set(:all, true) }
|
|
372
|
+
opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
|
|
373
|
+
"For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
|
|
374
|
+
set(:test_command, v)
|
|
375
|
+
end
|
|
355
376
|
opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
|
|
356
377
|
"--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
|
|
357
378
|
set(:guess_tests, v)
|
|
@@ -368,11 +389,13 @@ module Saboteur
|
|
|
368
389
|
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
369
390
|
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
370
391
|
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
371
|
-
opts.on("--format FORMAT", %w[text agent], "text
|
|
372
|
-
"strict exit codes") { |v| @format = v }
|
|
392
|
+
opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
|
|
373
393
|
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
374
394
|
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
375
|
-
"a crash prints its backtrace")
|
|
395
|
+
"a crash prints its backtrace") do
|
|
396
|
+
@verbose = true
|
|
397
|
+
set(:verbose, true)
|
|
398
|
+
end
|
|
376
399
|
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
377
400
|
@color = v ? :always : :never
|
|
378
401
|
end
|
|
@@ -388,12 +411,8 @@ module Saboteur
|
|
|
388
411
|
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
389
412
|
end
|
|
390
413
|
|
|
391
|
-
def add_subject(expr)
|
|
392
|
-
(options[:subjects] ||= []) << expr
|
|
393
|
-
end
|
|
414
|
+
def add_subject(expr) = (options[:subjects] ||= []) << expr
|
|
394
415
|
|
|
395
|
-
def set(key, value)
|
|
396
|
-
options[key] = value
|
|
397
|
-
end
|
|
416
|
+
def set(key, value) = (options[key] = value)
|
|
398
417
|
end
|
|
399
418
|
end
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -22,13 +22,27 @@ module Saboteur
|
|
|
22
22
|
"integration" => "rspec",
|
|
23
23
|
"require" => [],
|
|
24
24
|
"include" => ["lib/**/*.rb"],
|
|
25
|
-
|
|
25
|
+
# Spec and test files vendored under lib/ (capybara ships its driver
|
|
26
|
+
# suite at lib/capybara/spec/**) are never mutation subjects (#504).
|
|
27
|
+
# A config's own c.exclude replaces this list wholesale.
|
|
28
|
+
"exclude" => ["lib/**/spec/**/*", "lib/**/test/**/*", "lib/**/tests/**/*",
|
|
29
|
+
"lib/**/*_spec.rb", "lib/**/*_test.rb", "lib/**/test_*.rb"],
|
|
26
30
|
"arid" => MutationSource::Builtin::DEFAULT_ARID,
|
|
27
31
|
"whole_file" => [],
|
|
32
|
+
# [{ "pattern" => "app/payments/**/*", "min_score" => 95.0 }], from
|
|
33
|
+
# repeatable c.score_gate calls (#473). --threshold stays the global
|
|
34
|
+
# fallback.
|
|
35
|
+
"score_gates" => [],
|
|
28
36
|
"hazard" => MutationSource::HazardGuard::DEFAULT_HAZARD,
|
|
29
37
|
"allow_hazardous" => false,
|
|
30
|
-
|
|
38
|
+
# true, false, "strict" or "seatbelt": strict refuses to run without full Landlock;
|
|
39
|
+
# seatbelt opts into the macOS Seatbelt sandbox (on Linux it behaves like true).
|
|
40
|
+
"sandbox" => true,
|
|
31
41
|
"sandbox_writable" => [],
|
|
42
|
+
# off, survivors, or decisive (#476): one reordered confirmation run
|
|
43
|
+
# per new survivor (and per fresh kill in decisive); a disagreement
|
|
44
|
+
# becomes unverified, outside the score.
|
|
45
|
+
"flaky_check" => "off",
|
|
32
46
|
"timeout_factor" => 3.0,
|
|
33
47
|
"timeout_min" => 5.0,
|
|
34
48
|
"baseline_timeout" => 300.0,
|
|
@@ -56,6 +70,7 @@ module Saboteur
|
|
|
56
70
|
"since" => nil,
|
|
57
71
|
"cache" => true,
|
|
58
72
|
"incremental" => false,
|
|
73
|
+
"shard" => nil,
|
|
59
74
|
"allow_focus" => false,
|
|
60
75
|
"fail_fast" => false,
|
|
61
76
|
"guess_tests" => false,
|
|
@@ -67,10 +82,14 @@ module Saboteur
|
|
|
67
82
|
"max_duration" => nil,
|
|
68
83
|
"max_duration_ok" => false,
|
|
69
84
|
"tests" => [],
|
|
85
|
+
"test_command" => nil,
|
|
70
86
|
"fingerprint_extra" => [],
|
|
71
87
|
"threshold" => nil,
|
|
72
88
|
"max_timeouts" => nil,
|
|
73
|
-
"max_errors" => nil
|
|
89
|
+
"max_errors" => nil,
|
|
90
|
+
# --verbose also lands here, so boot-time output filters (the rspec
|
|
91
|
+
# load-error report, #608) can keep their raw text behind the flag.
|
|
92
|
+
"verbose" => false
|
|
74
93
|
}.freeze
|
|
75
94
|
|
|
76
95
|
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes].freeze
|
|
@@ -160,14 +179,30 @@ module Saboteur
|
|
|
160
179
|
matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
161
180
|
excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
162
181
|
(matched - excluded).select { |file| file.end_with?(".rb") }
|
|
163
|
-
.
|
|
182
|
+
.filter_map { |file| real_source_path(file) }
|
|
164
183
|
.uniq.sort
|
|
165
184
|
end
|
|
166
185
|
|
|
186
|
+
# A broken symlink matches Dir.glob but has no real path: dangling
|
|
187
|
+
# (ENOENT), a loop (ELOOP) or through a regular file (ENOTDIR). It is
|
|
188
|
+
# not mutation source, so it is skipped rather than crashing the run
|
|
189
|
+
# (#503). A regular file that cannot be resolved still raises.
|
|
190
|
+
def real_source_path(file)
|
|
191
|
+
path = File.expand_path(Text.utf8_identity(file), root)
|
|
192
|
+
File.realpath(path)
|
|
193
|
+
rescue SystemCallError
|
|
194
|
+
raise unless File.symlink?(path)
|
|
195
|
+
end
|
|
196
|
+
|
|
167
197
|
# Patterns that match no file. A pattern goes stale when files move;
|
|
168
198
|
# an exclude that silently stops excluding is a hazard, so say so.
|
|
169
199
|
def unmatched_patterns
|
|
170
200
|
%w[include exclude].flat_map do |key|
|
|
201
|
+
# The default exclude shields (#504) match nothing on a healthy
|
|
202
|
+
# project - that is their job - so only a configured exclude can go
|
|
203
|
+
# stale enough to warn about.
|
|
204
|
+
next [] if key == "exclude" && !explicit?("exclude")
|
|
205
|
+
|
|
171
206
|
self[key].select { |pattern| Dir.glob(pattern, base: root).empty? }.map { |pattern| "#{key}: #{pattern}" }
|
|
172
207
|
end
|
|
173
208
|
end
|
|
@@ -237,6 +272,41 @@ module Saboteur
|
|
|
237
272
|
validate_matrix!
|
|
238
273
|
validate_test_selection!
|
|
239
274
|
validate_gates!
|
|
275
|
+
validate_score_gates!
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Per-path gates (#473): normalize each entry to string pattern /
|
|
279
|
+
# float floor, and fail loud on a pattern no included file matches -
|
|
280
|
+
# a gate that matches nothing would pass vacuously forever.
|
|
281
|
+
def validate_score_gates!
|
|
282
|
+
gates = self["score_gates"]
|
|
283
|
+
return if gates.empty?
|
|
284
|
+
|
|
285
|
+
normalized = gates.map { |gate| normalize_score_gate(gate) }
|
|
286
|
+
prefix = File.join(File.realpath(root), "")
|
|
287
|
+
relative = included_files.map { |file| file.delete_prefix(prefix) }
|
|
288
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
289
|
+
normalized.each do |gate|
|
|
290
|
+
next if relative.any? { |file| File.fnmatch?(gate["pattern"], file, flags) }
|
|
291
|
+
|
|
292
|
+
raise ConfigError, "score_gate #{gate['pattern']} matches no included file"
|
|
293
|
+
end
|
|
294
|
+
@values["score_gates"] = normalized
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def normalize_score_gate(gate)
|
|
298
|
+
pattern = gate["pattern"].to_s
|
|
299
|
+
begin
|
|
300
|
+
floor = Float(gate["min_score"])
|
|
301
|
+
rescue ArgumentError, TypeError
|
|
302
|
+
raise ConfigError, "score_gate min_score must be between 0 and 100, got #{gate['min_score'].inspect}"
|
|
303
|
+
end
|
|
304
|
+
unless floor.finite? && floor.between?(0, 100)
|
|
305
|
+
raise ConfigError, "score_gate min_score must be between 0 and 100, got #{gate['min_score'].inspect}"
|
|
306
|
+
end
|
|
307
|
+
raise ConfigError, "score_gate pattern must not be empty" if pattern.empty?
|
|
308
|
+
|
|
309
|
+
{ "pattern" => pattern, "min_score" => floor }
|
|
240
310
|
end
|
|
241
311
|
|
|
242
312
|
def validate_gates!
|
|
@@ -270,6 +340,28 @@ module Saboteur
|
|
|
270
340
|
raise ConfigError, "test_selection must be one of: auto, all"
|
|
271
341
|
end
|
|
272
342
|
|
|
343
|
+
# --test-command replaces the whole in-process machinery, on the
|
|
344
|
+
# EFFECTIVE config: CLI flags and config-file entries alike (#305).
|
|
345
|
+
# The command runs unsandboxed, so "strict" cannot be honored.
|
|
346
|
+
def validate_test_command!
|
|
347
|
+
command = self["test_command"]
|
|
348
|
+
# nil alone means unset: false, arrays and numbers are all rejected,
|
|
349
|
+
# never coerced and never sent to String methods (#305 QA).
|
|
350
|
+
raise ConfigError, "test_command must be a string" unless command.nil? || command.is_a?(String)
|
|
351
|
+
raise ConfigError, "test_command must not be blank" if command&.strip&.empty?
|
|
352
|
+
|
|
353
|
+
return if command.to_s.empty?
|
|
354
|
+
raise ConfigError, "test_command cannot be combined with tests" unless self["tests"].empty?
|
|
355
|
+
raise ConfigError, "test_command cannot be combined with incremental" if self["incremental"]
|
|
356
|
+
unless self["flaky_check"] == "off"
|
|
357
|
+
raise ConfigError, "test_command cannot be combined with flaky_check: an external run has no " \
|
|
358
|
+
"in-process scheduler to rerun the decisive tests through"
|
|
359
|
+
end
|
|
360
|
+
return unless self["sandbox"] == "strict"
|
|
361
|
+
|
|
362
|
+
raise ConfigError, 'test_command runs the command unsandboxed, so sandbox "strict" cannot be honored'
|
|
363
|
+
end
|
|
364
|
+
|
|
273
365
|
def validate_reeval!
|
|
274
366
|
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
275
367
|
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
@@ -277,8 +369,11 @@ module Saboteur
|
|
|
277
369
|
|
|
278
370
|
def validate!
|
|
279
371
|
raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
|
|
280
|
-
unless [true, false, "strict"].include?(self["sandbox"])
|
|
281
|
-
raise ConfigError, 'sandbox must be true, false, or "
|
|
372
|
+
unless [true, false, "strict", "seatbelt"].include?(self["sandbox"])
|
|
373
|
+
raise ConfigError, 'sandbox must be true, false, "strict", or "seatbelt"'
|
|
374
|
+
end
|
|
375
|
+
unless %w[off survivors decisive].include?(self["flaky_check"])
|
|
376
|
+
raise ConfigError, "flaky_check must be one of: off, survivors, decisive"
|
|
282
377
|
end
|
|
283
378
|
|
|
284
379
|
validate_mutant_limit!
|
|
@@ -292,6 +387,7 @@ module Saboteur
|
|
|
292
387
|
|
|
293
388
|
validate_reeval!
|
|
294
389
|
validate_operator_config!
|
|
390
|
+
validate_test_command!
|
|
295
391
|
end
|
|
296
392
|
|
|
297
393
|
public
|
data/lib/saboteur/config_file.rb
CHANGED
|
@@ -9,23 +9,36 @@ module Saboteur
|
|
|
9
9
|
# boots, so it cannot reference app constants - plain Ruby and ENV only.
|
|
10
10
|
module ConfigFile
|
|
11
11
|
# The assignment target. One setter per Config key; anything else is an
|
|
12
|
-
# unknown setting.
|
|
13
|
-
# errors can point at it.
|
|
12
|
+
# unknown setting.
|
|
14
13
|
class Settings
|
|
15
14
|
def initialize
|
|
16
15
|
@values = {}
|
|
17
|
-
@lines = {}
|
|
18
16
|
end
|
|
19
17
|
|
|
20
|
-
attr_reader :values
|
|
18
|
+
attr_reader :values
|
|
21
19
|
|
|
22
|
-
Config::DEFAULTS.
|
|
20
|
+
(Config::DEFAULTS.keys - ["integration"]).each do |key|
|
|
23
21
|
define_method("#{key}=") do |value|
|
|
24
22
|
@values[key] = value
|
|
25
|
-
@lines[key] = caller_locations(1, 1).first.lineno
|
|
26
23
|
end
|
|
27
24
|
end
|
|
28
25
|
|
|
26
|
+
def integration=(value)
|
|
27
|
+
line = caller_locations(1, 1).fetch(0).lineno
|
|
28
|
+
unless value.is_a?(String)
|
|
29
|
+
raise ConfigError, "#{line}: integration must be a String, got #{value.inspect}; use \"rspec\""
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@values["integration"] = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Repeatable per-path score gates (#473), the one setting that takes
|
|
36
|
+
# arguments: c.score_gate "app/payments/**/*", min_score: 95. Values
|
|
37
|
+
# are validated later, on the effective Config.
|
|
38
|
+
def score_gate(pattern, min_score:)
|
|
39
|
+
(@values["score_gates"] ||= []) << { "pattern" => pattern, "min_score" => min_score }
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
Config::INTEGER_KEYS.each do |key|
|
|
30
43
|
define_method("#{key}=") do |value|
|
|
31
44
|
unless value.is_a?(Integer)
|
|
@@ -33,7 +46,6 @@ module Saboteur
|
|
|
33
46
|
raise ConfigError, "#{line}: #{key} must be an Integer, got #{value.inspect}"
|
|
34
47
|
end
|
|
35
48
|
@values[key] = value
|
|
36
|
-
@lines[key] = caller_locations(1, 1).first.lineno
|
|
37
49
|
end
|
|
38
50
|
end
|
|
39
51
|
end
|