saboteur 0.8.1 → 0.9.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 +100 -0
- data/README.md +10 -0
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +3 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +3 -0
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli.rb +29 -307
- data/lib/saboteur/config.rb +72 -6
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +20 -12
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +1 -1
- data/lib/saboteur/doctor.rb +6 -0
- data/lib/saboteur/engine/baseline_guard.rb +50 -13
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +15 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +275 -22
- data/lib/saboteur/engine/diagnostics.rb +23 -6
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +1 -0
- data/lib/saboteur/engine/load_coverage.rb +54 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +7 -7
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +5 -5
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +5 -2
- data/lib/saboteur/engine/scheduler.rb +1 -1
- data/lib/saboteur/engine/selection_announcement.rb +1 -0
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +33 -37
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +82 -18
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +41 -9
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +105 -1
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/sandbox.rb +30 -17
- data/lib/saboteur/execution/supervisor.rb +40 -7
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +46 -7
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/builtin.rb +32 -22
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +5 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +9 -1
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +29 -4
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +136 -10
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +8 -0
- metadata +44 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30c6af46486b9fc1138b568c45934cc895f786a79ea5215607f493a5a7d5af68
|
|
4
|
+
data.tar.gz: a7a8038afc6166347fac807028cf7f45acecbb7e4c27e32f8917c4167b805bac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66817191adf235c1fb6adb41d3218704557d2d3305fab299f17cc38bb3b392733f0a52615d84aadb5e4fd940046db9336de9efa178b205d58cc8e1c61374c25a
|
|
7
|
+
data.tar.gz: 132649f6b81d5ee312341a032df36b890af481704deee6c4b5cd4a013736c857ddc7887b27318409a1db05ea2621cbc8b2791ff4d0c40d3509810a6e13f7a405
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,106 @@ All notable changes are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Five new mutation operators: `and_assign` drops the guard on `&&=`
|
|
14
|
+
(#873), `jump_value` replaces unchecked early-exit values (#875),
|
|
15
|
+
`rescue_removal` removes exception handlers no test reaches (#877),
|
|
16
|
+
`when_removal` removes case branches no test takes (#887), and
|
|
17
|
+
`unary_removal` removes signs and complements no test sees (#890).
|
|
18
|
+
- `--resume DIR` keeps verdicts in a directory across runs, with a
|
|
19
|
+
manifest check on the directory (#840, #831), and `--incremental`
|
|
20
|
+
checkpoints verdicts while the run is going (#820, documented in #822).
|
|
21
|
+
- Partial and parallel coverage: `c.coverage_parts` deals the suite into
|
|
22
|
+
parts built in parallel (#741: #769, #772, #774, #776), keeps each
|
|
23
|
+
finished part when a build stops (#860), and reuses the saved parts
|
|
24
|
+
when a parallel build is rerun (#862). The partial map merge and an
|
|
25
|
+
opt-in shadow run compare a partial rebuild against the full map
|
|
26
|
+
(#829, #832), and `partial_coverage = "use"` skips the full build when
|
|
27
|
+
only spec files or covered sources changed, rerunning just the stale
|
|
28
|
+
spec files and merging them into the earlier map (#918).
|
|
29
|
+
- TracePoint coverage recorders: `MethodCalls` records Ruby method
|
|
30
|
+
entries (#794, #795), a line-hits recorder reads branches once per
|
|
31
|
+
spec file (#894, #897), and `c.coverage_recorder = "trace"` picks the
|
|
32
|
+
TracePoint build (#898).
|
|
33
|
+
- Constant-reference spec selection: a spec that requires a spec-less
|
|
34
|
+
file counts as its reference reader (#876, #879), a stop that keeps a
|
|
35
|
+
hub file's cap says how many specs reference it (#881), and the agent
|
|
36
|
+
JSON provenance reports and flags findings whose specs were chosen by
|
|
37
|
+
reference (#884, #885).
|
|
38
|
+
- The GitHub Action posts surviving mutants as pull-request review
|
|
39
|
+
comments on the lines the PR head has (#788, #789, #793), matches the
|
|
40
|
+
review comment bot login without regard to case (#791), documents the
|
|
41
|
+
review comment inputs (#790), and exposes max-timeouts and max-errors
|
|
42
|
+
as inputs (#246, #767).
|
|
43
|
+
- Integration targets: a target can name its own gemfile (#900), carry
|
|
44
|
+
patch, env and recorder options (#899), and run setup commands and add
|
|
45
|
+
gems (#910). doorkeeper, paper_trail, cancancan and lobsters join the
|
|
46
|
+
recorder-parity targets (#901, #908, #912).
|
|
47
|
+
- The JSON report adds per-gate score breakdowns (#473, #766) and a
|
|
48
|
+
`saboteur.complete` field (#784).
|
|
49
|
+
- A real-bug corpus runner with a manual CI workflow, and the s-z and
|
|
50
|
+
i-r corpus slices (#652: #915, #916).
|
|
51
|
+
- A coverage cache miss says why it missed and what changed (#812), and
|
|
52
|
+
spec shards balance by recorded per-example run time (#747, #748).
|
|
53
|
+
- Warnings across the survivor, sandbox grant, shadow, worker, budget
|
|
54
|
+
and `--since`/`--subject` messages lead with the outcome and name the
|
|
55
|
+
remedy, per the documented message style (#332 series, #796).
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- `--since` ignores a line whose only change is its trailing comment
|
|
60
|
+
(#892, fixed in #921).
|
|
61
|
+
- `script/rspec-shard` no longer dies of SIGPIPE on a long spec list
|
|
62
|
+
(#878).
|
|
63
|
+
- `or_assign` no longer mutates constant targets inside a def (#874).
|
|
64
|
+
- A test that suspends Coverage no longer breaks the map build; Coverage
|
|
65
|
+
resumes after it (#869).
|
|
66
|
+
- Worker pool cleanup no longer leaks live entries or masks errors, and
|
|
67
|
+
keeps unregistering workers when the orphan sweep goes blind (#762:
|
|
68
|
+
#770, #771); containment and the live registry survive a blind process
|
|
69
|
+
table (#762: #764).
|
|
70
|
+
- A timed-out Gemfile resolver no longer blocks on escaped-grandchild
|
|
71
|
+
pipes, and the resolver-pipe specs reap escaped grandchildren (#734,
|
|
72
|
+
#750). A `path:` source equal to the project root rebases onto the
|
|
73
|
+
copy root (#729), external test-command `path:` sources are refused or
|
|
74
|
+
rebased (#644, #721), and the BUNDLE_GEMFILE pin gaps close with
|
|
75
|
+
lockfile-mismatch refusals (#732).
|
|
76
|
+
- A cached coverage map is read as untrusted input: its field types,
|
|
77
|
+
durations and loaded files are checked, non-finite values are
|
|
78
|
+
rejected, and the inputs snapshot binds to the map and the runtime
|
|
79
|
+
(#853, #854, #859, #825). Stored verdicts are type-checked before
|
|
80
|
+
reuse (#842).
|
|
81
|
+
- Cache files and the coverage map write through an exclusive private
|
|
82
|
+
temp file (#846, #850).
|
|
83
|
+
- Registry DSL blocks in Ohai/Puppet style promote their defs to
|
|
84
|
+
subjects (#700, #727).
|
|
85
|
+
- The baseline checks the unchanged suite in the coverage build's parts
|
|
86
|
+
instead of a separate pass (#930).
|
|
87
|
+
|
|
88
|
+
### Security
|
|
89
|
+
|
|
90
|
+
- CI workflow-command injection is closed at every sink: the unmatched
|
|
91
|
+
whole_file pattern (#856), repo-controlled file names in the
|
|
92
|
+
constant-safety warnings (#857), name_list items (#858),
|
|
93
|
+
repo-controlled names in the syntax, custom map and sandbox grant
|
|
94
|
+
warnings (#870), every Diagnostics warning line (#871), the terminal
|
|
95
|
+
report at one choke point (#872), the stop and gate messages (#919),
|
|
96
|
+
and the CLI error sinks (#917, fixed in #920).
|
|
97
|
+
- The review comment planner is hardened against untrusted input (#783),
|
|
98
|
+
and the Action fetches the pull request base with the token in a step
|
|
99
|
+
of its own, with the token-handling scripts pinned (#797, #798).
|
|
100
|
+
|
|
101
|
+
### Documentation
|
|
102
|
+
|
|
103
|
+
- The getting-started guide shows a gem-installed run (#815), the
|
|
104
|
+
config-key table documents every key and the Rails config points at it
|
|
105
|
+
(#809), the Action guide documents a per-path score gate (#817),
|
|
106
|
+
`--incremental` verdict saving is documented (#822), and the warning
|
|
107
|
+
and note style convention is written down (#796).
|
|
108
|
+
|
|
9
109
|
## [0.8.1] - 2026-10-18
|
|
10
110
|
|
|
11
111
|
### Added
|
data/README.md
CHANGED
|
@@ -127,6 +127,16 @@ the macro itself `ran at load` instead of letting its mutants survive.
|
|
|
127
127
|
before a kill is rejected, so its mutants are still scored.
|
|
128
128
|
- **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
|
|
129
129
|
JSON report, and a stable agent JSON API with a deprecation policy.
|
|
130
|
+
The GitHub Action posts surviving mutants as review comments on the
|
|
131
|
+
diff lines of the pull request.
|
|
132
|
+
- **Resumable runs.** `--resume DIR` keeps verdicts across runs and
|
|
133
|
+
`--incremental` checkpoints them while the run is going, so a stopped
|
|
134
|
+
run picks up where it left off.
|
|
135
|
+
- **Faster coverage builds.** `c.coverage_parts` builds the coverage map
|
|
136
|
+
in parallel parts, `partial_coverage = "use"` rebuilds only the stale
|
|
137
|
+
spec files and merges them into the saved map, and
|
|
138
|
+
`c.coverage_recorder = "trace"` builds the map with a TracePoint
|
|
139
|
+
recorder.
|
|
130
140
|
- **A plan before the run.** `saboteur run --dry-run --format json` prints
|
|
131
141
|
the versioned [mutant inventory](docs/dry-run-json.md) - every planned
|
|
132
142
|
mutant with its stable digest and edits - without booting the suite.
|
data/exe/saboteur
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# (one line, invalid bytes scrubbed, bounded length) still exits 3.
|
|
12
12
|
begin
|
|
13
13
|
require "coverage"
|
|
14
|
-
Coverage.setup(lines: true,
|
|
14
|
+
Coverage.setup(lines: true, branches: true) if Coverage.state == :idle
|
|
15
15
|
|
|
16
16
|
require "saboteur/crash_report"
|
|
17
17
|
rescue LoadError, StandardError => e
|
|
@@ -27,7 +27,11 @@ end
|
|
|
27
27
|
# ones. The engine suspends counting right after boot and reads the
|
|
28
28
|
# counters as the map's load lines (a forked child would not inherit them).
|
|
29
29
|
begin
|
|
30
|
-
|
|
30
|
+
if ARGV.first == "run" && Coverage.state == :suspended
|
|
31
|
+
require "saboteur/method_calls"
|
|
32
|
+
Saboteur::MethodCalls.boot
|
|
33
|
+
Coverage.resume
|
|
34
|
+
end
|
|
31
35
|
|
|
32
36
|
require "saboteur/cli"
|
|
33
37
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
|
+
require_relative "deep_source"
|
|
5
6
|
require_relative "subject"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
@@ -24,6 +25,8 @@ module Saboteur
|
|
|
24
25
|
result = Prism.parse(File.binread(file), filepath: file)
|
|
25
26
|
return [] if result.failure?
|
|
26
27
|
|
|
28
|
+
DeepSource.check!(result.value, file)
|
|
29
|
+
|
|
27
30
|
found = []
|
|
28
31
|
walk(result.value, [], found)
|
|
29
32
|
found
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative "identity"
|
|
5
5
|
require_relative "completeness"
|
|
6
|
+
require_relative "../config"
|
|
6
7
|
require_relative "../engine/run_fingerprint"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
@@ -11,7 +12,7 @@ module Saboteur
|
|
|
11
12
|
# This does not attest external database, network or environment state.
|
|
12
13
|
class RunEvidence
|
|
13
14
|
SAFE_DROPS = %w[unchanged duplicate].freeze
|
|
14
|
-
OUTPUT_SETTINGS =
|
|
15
|
+
OUTPUT_SETTINGS = Config::OUTPUT_KEYS
|
|
15
16
|
|
|
16
17
|
def initialize(config:, extra_files: [])
|
|
17
18
|
@config = config
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Agent-mode provenance: the record a JSON report carries so its rerun
|
|
7
|
+
# and replay commands reproduce the run exactly (#334). Extracted from
|
|
8
|
+
# CLI; the builder and its settings helpers are private to the mixin.
|
|
9
|
+
module AgentProvenance
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def agent_provenance(report, tree, engine)
|
|
13
|
+
Reporter::Agent::Provenance.new(
|
|
14
|
+
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
15
|
+
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
16
|
+
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
|
|
17
|
+
sources: @sources || [],
|
|
18
|
+
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
19
|
+
incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings,
|
|
20
|
+
referenced: report.referenced
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The settings keys typed on the command line: a typed empty operator
|
|
25
|
+
# list may clear a config value, so the generated commands keep it.
|
|
26
|
+
def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
|
|
27
|
+
|
|
28
|
+
# A whole-suite run records its scope as --all, so the generated rerun
|
|
29
|
+
# and replay commands do not default back to inference (which can exit
|
|
30
|
+
# 2 in a project with a spec-less source file).
|
|
31
|
+
def agent_settings
|
|
32
|
+
settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
33
|
+
.merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
|
|
34
|
+
# The effective mode: named tests win over a configured whole-suite
|
|
35
|
+
# selection by precedence, so all is recorded only when no tests
|
|
36
|
+
# resolved - otherwise the follow-up commands would carry both --all
|
|
37
|
+
# and --test, which the CLI rejects.
|
|
38
|
+
settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
|
|
39
|
+
# CLI-only switches that change candidate availability or boot, so a
|
|
40
|
+
# printed rerun/replay must keep them (config-file settings like
|
|
41
|
+
# arid/hazard patterns, whole-file, require, rspec_args and rails ride
|
|
42
|
+
# along through --config or the default config path).
|
|
43
|
+
settings[:allow_hazardous] = true if options[:allow_hazardous]
|
|
44
|
+
settings[:allow_focus] = true if options[:allow_focus]
|
|
45
|
+
# An explicit false (--no-max-duration-ok over a config true) is a
|
|
46
|
+
# setting too: dropping it would silently re-enable the config value.
|
|
47
|
+
settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
|
|
48
|
+
settings
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def agent_mode
|
|
52
|
+
return "replay" if options[:id]
|
|
53
|
+
return "diff" if config["since"]
|
|
54
|
+
|
|
55
|
+
@sources&.any? ? "files" : "full"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The uncommitted-work warning printed before a run (#334). Extracted
|
|
7
|
+
# from CLI.
|
|
8
|
+
module DirtyTreeNotice
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Mutants run your code, and a mutant of code that writes or deletes files
|
|
12
|
+
# can change the tree. Say so up front when uncommitted work is at stake.
|
|
13
|
+
# The scope the warning names is the configured one: positional source
|
|
14
|
+
# operands narrow the run after the warning, so the include/exclude they
|
|
15
|
+
# imply stay out of its config (#309, #503 review).
|
|
16
|
+
def warn_dirty_tree
|
|
17
|
+
warning = if @sources
|
|
18
|
+
Config.load(path: options[:config],
|
|
19
|
+
overrides: overrides.except(:include,
|
|
20
|
+
:exclude))
|
|
21
|
+
else
|
|
22
|
+
config
|
|
23
|
+
end
|
|
24
|
+
DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# `saboteur doctor`: environment report and optional unchanged suite
|
|
7
|
+
# passes, never mutations (#334). Extracted from CLI by command.
|
|
8
|
+
module DoctorCommand
|
|
9
|
+
def doctor_parser
|
|
10
|
+
OptionParser.new do |opts|
|
|
11
|
+
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
|
|
12
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
13
|
+
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
14
|
+
opts.on("--suite", "Also run the unchanged suite twice (slow on a large suite)") { set(:suite, true) }
|
|
15
|
+
opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
|
|
16
|
+
(options[:tests] ||= []) << value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def doctor_command = help_requested? ? show_help("doctor") : doctor
|
|
24
|
+
|
|
25
|
+
def doctor
|
|
26
|
+
doctor_parser.parse!(@argv)
|
|
27
|
+
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
28
|
+
|
|
29
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
30
|
+
Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
|
|
31
|
+
EXIT_OK
|
|
32
|
+
rescue ConfigError => e
|
|
33
|
+
@err.puts("saboteur: #{Text.display_safe_lines(e.message)}")
|
|
34
|
+
EXIT_USAGE
|
|
35
|
+
rescue Error => e
|
|
36
|
+
@err.puts("saboteur: #{Text.display_safe_lines(e.message)}")
|
|
37
|
+
EXIT_TOOL
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The detail line a "mostly errored" or "incomplete" exit message adds:
|
|
7
|
+
# the most common first line of the errored results' reasons, so a run
|
|
8
|
+
# that exits on its error count still names why (a worker killed by a
|
|
9
|
+
# signal, say) instead of leaving the cause in the stdout error list.
|
|
10
|
+
module ErrorCause
|
|
11
|
+
def self.incomplete(problem)
|
|
12
|
+
"saboteur: #{problem.size} mutant(s) errored or hit a resource limit, so the score is " \
|
|
13
|
+
"incomplete#{line(problem)}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
LIMIT = 200
|
|
17
|
+
|
|
18
|
+
# "" when no result carries a reason.
|
|
19
|
+
def self.line(errored)
|
|
20
|
+
reasons = errored.filter_map { |result| first_line(result.reason) }.reject(&:empty?)
|
|
21
|
+
return "" if reasons.empty?
|
|
22
|
+
|
|
23
|
+
# Worker numbers and pids differ between otherwise identical deaths.
|
|
24
|
+
group = reasons.group_by { |reason| reason.delete("0-9") }.values.max_by(&:size)
|
|
25
|
+
"\n most common cause (#{group.size} of #{errored.size}): #{group.first}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A reason can carry invalid bytes, terminal escapes or a huge line,
|
|
29
|
+
# and this runs on the way to the exit code: scrub, escape controls and
|
|
30
|
+
# cap before anything groups or prints it, and never raise.
|
|
31
|
+
def self.first_line(reason)
|
|
32
|
+
text = Text.utf8(reason.to_s).lines.map(&:strip).find { |line| !line.empty? }.to_s
|
|
33
|
+
text = Text.display_safe(text.gsub(/\e\[[0-9;]*[A-Za-z]/, ""))
|
|
34
|
+
text.length > LIMIT ? "#{text[0, LIMIT]}..." : text
|
|
35
|
+
rescue StandardError
|
|
36
|
+
reason.to_s.dump[0, LIMIT]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Execution, cache and hazard flags for `saboteur run` (#334).
|
|
7
|
+
module ExecutionOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_execution_options(opts)
|
|
11
|
+
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
12
|
+
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
13
|
+
add_run_budget_options(opts)
|
|
14
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
15
|
+
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
16
|
+
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
17
|
+
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
18
|
+
opts.on("--allow-hazardous",
|
|
19
|
+
"Also run mutants inside destructive calls (rm_rf, system, ...)") { set(:allow_hazardous, true) }
|
|
20
|
+
opts.on("--fail-fast",
|
|
21
|
+
"Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
|
|
22
|
+
add_reuse_options(opts)
|
|
23
|
+
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
24
|
+
set(:allow_focus, true)
|
|
25
|
+
end
|
|
26
|
+
opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
|
|
27
|
+
"mutated files") { set(:all, true) }
|
|
28
|
+
opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
|
|
29
|
+
"For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
|
|
30
|
+
set(:test_command, v)
|
|
31
|
+
end
|
|
32
|
+
opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
|
|
33
|
+
"--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
|
|
34
|
+
set(:guess_tests, v)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_reuse_options(opts)
|
|
39
|
+
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
40
|
+
opts.on("--resume DIR", "Keep verdicts in DIR and reuse them in a later run with the same inputs") do |v|
|
|
41
|
+
set(:resume, v)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -14,11 +14,9 @@ module Saboteur
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# The crash log lives with the other run state. A broken config (or a
|
|
17
|
-
# nil cache_dir) must not swallow the crash report itself
|
|
18
|
-
# back to no log.
|
|
17
|
+
# nil cache_dir) must not swallow the crash report itself: no log.
|
|
19
18
|
def crash_log_path
|
|
20
|
-
|
|
21
|
-
dir && File.join(dir, CrashReport::LOG_NAME)
|
|
19
|
+
config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
|
|
22
20
|
rescue StandardError
|
|
23
21
|
nil
|
|
24
22
|
end
|
|
@@ -34,7 +32,8 @@ module Saboteur
|
|
|
34
32
|
|
|
35
33
|
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
quality_failed = failed_quality_gate?(report)
|
|
36
|
+
gates.failed?(report, @err, record_cause: !quality_failed) || quality_failed ? EXIT_SURVIVORS : EXIT_OK
|
|
38
37
|
end
|
|
39
38
|
|
|
40
39
|
# A spent --max-duration budget exits 4 on its own, before the gates:
|
|
@@ -62,7 +61,7 @@ module Saboteur
|
|
|
62
61
|
def unverifiable_scope?(report)
|
|
63
62
|
if unverifiable_review?(report)
|
|
64
63
|
report.skipped_subjects.each do |subject, reason|
|
|
65
|
-
@err.puts(
|
|
64
|
+
@err.puts(subject.skipped_gate_sentence(reason, report.root))
|
|
66
65
|
end
|
|
67
66
|
return true
|
|
68
67
|
end
|
|
@@ -86,10 +85,9 @@ module Saboteur
|
|
|
86
85
|
def mostly_errored?(report)
|
|
87
86
|
return false unless report.majority_errored?
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
errors
|
|
91
|
-
|
|
92
|
-
"fix the errors and run again")
|
|
88
|
+
errored = report.executed.select { |result| result.status == :error }
|
|
89
|
+
@err.puts("saboteur: fix the errors and run again\n #{errored.size} of #{report.executed.size} " \
|
|
90
|
+
"mutants errored, so the run tested almost nothing#{ErrorCause.line(errored)}")
|
|
93
91
|
true
|
|
94
92
|
end
|
|
95
93
|
|
|
@@ -159,10 +157,10 @@ module Saboteur
|
|
|
159
157
|
def incomplete_under_threshold?(report)
|
|
160
158
|
return false if config["threshold"].nil?
|
|
161
159
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
statuses = gates.judges_errors? ? %i[resource_limit] : %i[error resource_limit]
|
|
161
|
+
problem = report.results.select { |result| statuses.include?(result.status) }
|
|
162
|
+
@err.puts(ErrorCause.incomplete(problem)) if problem.any?
|
|
163
|
+
problem.any?
|
|
166
164
|
end
|
|
167
165
|
end
|
|
168
166
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Explicit test selection and quality-gate flags (#334).
|
|
7
|
+
module ExplicitFileOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_explicit_file_options(opts)
|
|
11
|
+
opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
|
|
12
|
+
(options[:tests] ||= []) << v
|
|
13
|
+
end
|
|
14
|
+
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
15
|
+
set(:threshold, v)
|
|
16
|
+
end
|
|
17
|
+
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The source and test operands of `saboteur run`, checked against the
|
|
7
|
+
# file system before anything boots (#334). Extracted from CLI.
|
|
8
|
+
module ExplicitSources
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Positional arguments name the files (or directories) to mutate. They
|
|
12
|
+
# replace the configured include and exclude patterns. Each must exist,
|
|
13
|
+
# so a typo is an error and not a run over zero subjects.
|
|
14
|
+
def use_explicit_sources
|
|
15
|
+
Selection::SourceScope.verify_no_specs!(@argv)
|
|
16
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
17
|
+
# --test may stand alone: the configured include (or the --since
|
|
18
|
+
# diff) names the sources. The files must still exist.
|
|
19
|
+
return if @argv.empty?
|
|
20
|
+
|
|
21
|
+
@sources = @argv.dup
|
|
22
|
+
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
23
|
+
options[:exclude] = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def source_pattern(source)
|
|
27
|
+
return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
|
|
28
|
+
raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
|
|
29
|
+
raise UsageError, "source file not found: #{source}" unless File.file?(source)
|
|
30
|
+
|
|
31
|
+
glob_escape(source)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# include is a list of glob patterns; a named file must match only itself.
|
|
35
|
+
def glob_escape(path)
|
|
36
|
+
path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
|
|
40
|
+
def require_existing_test(test)
|
|
41
|
+
path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
|
|
42
|
+
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Where a failed command speaks: stderr for people, and in agent mode the
|
|
7
|
+
# same failure as JSON on stdout (#334). Extracted from CLI.
|
|
8
|
+
module FailureOutput
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def usage_error(message = nil)
|
|
12
|
+
@err.puts(message) if message
|
|
13
|
+
@err.puts(USAGE)
|
|
14
|
+
agent_failure(message || USAGE, EXIT_USAGE)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def agent? = @format == "agent"
|
|
18
|
+
|
|
19
|
+
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
20
|
+
def agent_failure(message, exit_code)
|
|
21
|
+
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
22
|
+
exit_code
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Error messages interpolate repository-controlled names, so each line is
|
|
26
|
+
# made safe before it is printed (a newline then "::error::" in a file
|
|
27
|
+
# name must not become a workflow command in the CI log).
|
|
28
|
+
def report_failure(error, exit_code)
|
|
29
|
+
message = Text.display_safe_lines(error.message)
|
|
30
|
+
@err.puts("saboteur: #{message}")
|
|
31
|
+
agent_failure(message, exit_code)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# `saboteur help` and the `--help` of every command (#334). Extracted
|
|
7
|
+
# from CLI by command.
|
|
8
|
+
module HelpCommand
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def help_requested? = @argv.intersect?(%w[--help -h])
|
|
12
|
+
|
|
13
|
+
def show_help(command_name)
|
|
14
|
+
help = Help.new(option_lines: option_lines)
|
|
15
|
+
text = command_name ? help.command(command_name) : help.overview
|
|
16
|
+
return @out.puts(text) || EXIT_OK if text
|
|
17
|
+
|
|
18
|
+
@err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
|
|
19
|
+
EXIT_USAGE
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The --matrix flag for `saboteur run` (#334).
|
|
7
|
+
module MatrixOption
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_matrix_option(opts)
|
|
11
|
+
opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
|
|
12
|
+
set(:matrix, true)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Report destinations and text output flags for `saboteur run` (#334).
|
|
7
|
+
module OutputOptions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def define_output_options(opts)
|
|
11
|
+
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
12
|
+
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
13
|
+
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
14
|
+
opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
|
|
15
|
+
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
16
|
+
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
17
|
+
"a crash prints its backtrace") do
|
|
18
|
+
@verbose = true
|
|
19
|
+
set(:verbose, true)
|
|
20
|
+
end
|
|
21
|
+
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
22
|
+
@color = v ? :always : :never
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|