saboteur 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
data/lib/saboteur/help.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "json"
|
|
5
4
|
require_relative "config"
|
|
6
5
|
|
|
7
6
|
module Saboteur
|
|
@@ -18,20 +17,20 @@ module Saboteur
|
|
|
18
17
|
DOCUMENTATION = "https://github.com/vamsipavanmahesh/saboteur"
|
|
19
18
|
|
|
20
19
|
EXIT_CODE_MEANINGS = {
|
|
21
|
-
0 => "Success. Without --threshold: no unignored survivors
|
|
22
|
-
"it, even if mutants survived.",
|
|
20
|
+
0 => "Success. Without --threshold: no unignored survivors, and at most half of the executed mutants " \
|
|
21
|
+
"errored. With --threshold: the score is at or above it, even if mutants survived.",
|
|
23
22
|
1 => "Without --threshold: at least one mutant survived and is not on the ignore list. With --threshold " \
|
|
24
23
|
"(survivors alone no longer fail the run): the score is below it, or no mutant was tested so " \
|
|
25
|
-
"there is no score.",
|
|
26
|
-
2 => "Usage or configuration error: fix the command or
|
|
24
|
+
"there is no score. Also more timeout or errored mutants than --max-timeouts or --max-errors allow.",
|
|
25
|
+
2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
|
|
27
26
|
"mutant is only found after the target has booted. A named source or test file that does not exist.",
|
|
28
|
-
3 => "Tool error: saboteur could not finish, or
|
|
29
|
-
"also any errored or resource-limited mutant."
|
|
27
|
+
3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
|
|
28
|
+
"a run tested almost nothing). With --threshold, also any errored or resource-limited mutant."
|
|
30
29
|
}.freeze
|
|
31
30
|
|
|
32
31
|
DOCTOR_EXIT_CODE_MEANINGS = {
|
|
33
32
|
0 => "Healthy: the environment checks passed and the baseline passed twice in the same order.",
|
|
34
|
-
2 => "Usage or configuration error: fix the command or
|
|
33
|
+
2 => "Usage or configuration error: fix the command or config/saboteur.rb.",
|
|
35
34
|
3 => "A check failed: the target did not boot, or the baseline failed or changed between runs."
|
|
36
35
|
}.freeze
|
|
37
36
|
|
|
@@ -43,7 +42,8 @@ module Saboteur
|
|
|
43
42
|
"tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
|
|
44
43
|
"directories after run replace include and exclude; --test limits the specs that run; " \
|
|
45
44
|
"--threshold PERCENT replaces the survivor check: exit 0 when the score is at or above " \
|
|
46
|
-
"PERCENT, exit 1 when it is below."
|
|
45
|
+
"PERCENT, exit 1 when it is below. Inline comment syntax: " \
|
|
46
|
+
'# saboteur:disable-line [operator ...] reason="...". A reason is required.',
|
|
47
47
|
takes_options: true,
|
|
48
48
|
exit_codes: EXIT_CODE_MEANINGS,
|
|
49
49
|
examples: [
|
|
@@ -53,19 +53,43 @@ module Saboteur
|
|
|
53
53
|
["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
|
|
54
54
|
["Mutate named files against named specs, skipping the whole-suite coverage run",
|
|
55
55
|
"saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb"],
|
|
56
|
-
["CI gate: exit 1 when the score is below 90 percent", "saboteur run --threshold 90"]
|
|
56
|
+
["CI gate: exit 1 when the score is below 90 percent", "saboteur run --threshold 90"],
|
|
57
|
+
["AI agent: bounded JSON summary and strict exit codes", "saboteur run --agent --since origin/main"]
|
|
57
58
|
]
|
|
58
59
|
),
|
|
59
60
|
Command.new(
|
|
60
61
|
name: "doctor",
|
|
61
|
-
summary: "Check the environment and run the baseline twice, without mutating anything",
|
|
62
|
-
description: "Boots the target like a run
|
|
63
|
-
"suite twice in the same order with one seed and reports whether both
|
|
64
|
-
"It never mutates anything.",
|
|
62
|
+
summary: "Check the environment, and with --suite run the baseline twice, without mutating anything",
|
|
63
|
+
description: "Boots the target like a run and checks runtime, coverage and boot files. With --suite " \
|
|
64
|
+
"it also runs your suite twice in the same order with one seed and reports whether both " \
|
|
65
|
+
"runs passed; --test PATH limits those runs to some specs. It never mutates anything.",
|
|
65
66
|
takes_options: true,
|
|
66
67
|
exit_codes: DOCTOR_EXIT_CODE_MEANINGS,
|
|
67
68
|
examples: [["Check the setup before the first run", "saboteur doctor"],
|
|
68
|
-
["
|
|
69
|
+
["Also run the unchanged suite twice", "saboteur doctor --suite --seed 7"],
|
|
70
|
+
["Run only some specs twice", "saboteur doctor --test spec/models"]]
|
|
71
|
+
),
|
|
72
|
+
Command.new(
|
|
73
|
+
name: "baseline",
|
|
74
|
+
summary: "Check, propose or accept a fresh opt-in survivor baseline",
|
|
75
|
+
description: "Check runs every mutant fresh and reports new, changed or unmatched accepted survivors. " \
|
|
76
|
+
"Propose writes a proposal with private local execution evidence. Edit only survivor reasons; " \
|
|
77
|
+
"accept verifies current inputs and publishes it. No scores or execution are suppressed. " \
|
|
78
|
+
"With --epsilon POINTS, check also fails when the score drops more than POINTS percentage " \
|
|
79
|
+
"points below the accepted baseline's score. " \
|
|
80
|
+
"Only whole or subject scope; no diff, replay, incremental reuse or score threshold.",
|
|
81
|
+
takes_options: true,
|
|
82
|
+
exit_codes: { 0 => "Clean check, proposal written or accepted.",
|
|
83
|
+
1 => "New, changed or unmatched survivors, or a score drop beyond --epsilon.",
|
|
84
|
+
2 => "Invalid configuration, incompatible baseline or rejected acceptance.",
|
|
85
|
+
3 => "Incomplete execution or tool failure." },
|
|
86
|
+
examples: [["Prepare a reasoned proposal",
|
|
87
|
+
"saboteur baseline propose --baseline baseline.json --proposal proposal.json"],
|
|
88
|
+
["Publish reviewed reasons",
|
|
89
|
+
"saboteur baseline accept --baseline baseline.json --proposal proposal.json"],
|
|
90
|
+
["Check fresh outcomes", "saboteur baseline check --baseline baseline.json"],
|
|
91
|
+
["Also fail on a score drop of more than 2 points",
|
|
92
|
+
"saboteur baseline check --baseline baseline.json --epsilon 2"]]
|
|
69
93
|
),
|
|
70
94
|
Command.new(
|
|
71
95
|
name: "version",
|
|
@@ -83,12 +107,25 @@ module Saboteur
|
|
|
83
107
|
takes_options: false,
|
|
84
108
|
exit_codes: nil,
|
|
85
109
|
examples: [["Show everything", "saboteur help"], ["Show one command", "saboteur help run"]]
|
|
110
|
+
),
|
|
111
|
+
Command.new(
|
|
112
|
+
name: "agent-skill",
|
|
113
|
+
summary: "Print instructions for AI coding agents to paste into AGENTS.md or CLAUDE.md",
|
|
114
|
+
description: "Prints a short Markdown block that tells an agent when to run saboteur, how to read " \
|
|
115
|
+
"`saboteur run --agent`, and how to handle survivors.",
|
|
116
|
+
takes_options: false,
|
|
117
|
+
exit_codes: nil,
|
|
118
|
+
examples: [["Add the agent instructions to your repo", "saboteur agent-skill >> AGENTS.md"]]
|
|
86
119
|
)
|
|
87
120
|
].freeze
|
|
88
121
|
|
|
122
|
+
AGENT_EXIT_CODES = " With --agent the codes are stricter: 0 only when the run was complete and clean; " \
|
|
123
|
+
"1 survivors to review;\n 2 usage or config error; 3 incomplete (no coverage, caps, errors, " \
|
|
124
|
+
"nothing selected) or tool failure."
|
|
125
|
+
|
|
89
126
|
FILES = [
|
|
90
|
-
["
|
|
91
|
-
[".
|
|
127
|
+
["config/saboteur.rb", "Settings as Ruby (Saboteur.configure). Command line options override it."],
|
|
128
|
+
["config/saboteur_ignore.rb", "Accepted survivors: mutation digests with subject and a required reason."],
|
|
92
129
|
[".saboteur/report.json", "Stryker mutation-testing-report-schema JSON (--json changes the path)."],
|
|
93
130
|
[".saboteur/report.html", "Self-contained HTML report (--html changes the path)."],
|
|
94
131
|
[".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete."]
|
|
@@ -169,18 +206,21 @@ module Saboteur
|
|
|
169
206
|
def exit_codes_section(codes, command_name = nil)
|
|
170
207
|
rows = codes.map { |code, meaning| " #{code} #{meaning}" }
|
|
171
208
|
title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
|
|
172
|
-
"#{
|
|
209
|
+
note = codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
|
|
210
|
+
"#{title}\n#{rows.join("\n")}\n#{note}"
|
|
173
211
|
end
|
|
174
212
|
|
|
175
213
|
def config_section
|
|
176
|
-
rows = Config::DEFAULTS.map { |key, value| "
|
|
177
|
-
intro = "Every
|
|
178
|
-
"CONFIG FILE (
|
|
214
|
+
rows = Config::DEFAULTS.map { |key, value| " c.#{key} = #{describe_default(value)}" }
|
|
215
|
+
intro = "Every setting with its default value, as a Ruby assignment. Unknown settings are an error."
|
|
216
|
+
"CONFIG FILE (config/saboteur.rb)\n#{intro}\n#{rows.join("\n")}\n"
|
|
179
217
|
end
|
|
180
218
|
|
|
181
|
-
#
|
|
219
|
+
# Ruby literals, so a copied line loads as the same value: inspect prints
|
|
220
|
+
# nil as "nil" (JSON would print null, which is not Ruby) and quotes
|
|
221
|
+
# strings and arrays the way Ruby reads them back.
|
|
182
222
|
def describe_default(value)
|
|
183
|
-
|
|
223
|
+
value.inspect
|
|
184
224
|
end
|
|
185
225
|
|
|
186
226
|
def files_section
|
data/lib/saboteur/ignore_list.rb
CHANGED
|
@@ -1,40 +1,94 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "yaml"
|
|
5
|
-
|
|
6
4
|
module Saboteur
|
|
7
|
-
#
|
|
5
|
+
# Entry point used by config/saboteur_ignore.rb.
|
|
6
|
+
def self.ignore
|
|
7
|
+
collector = IgnoreList.current_collector
|
|
8
|
+
raise "Saboteur.ignore is only valid while config/saboteur_ignore.rb loads" unless collector
|
|
9
|
+
|
|
10
|
+
yield collector
|
|
11
|
+
collector
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Survivors the team has accepted. config/saboteur_ignore.rb declares them:
|
|
8
15
|
#
|
|
9
|
-
#
|
|
10
|
-
# mutation
|
|
11
|
-
#
|
|
16
|
+
# Saboteur.ignore do |i|
|
|
17
|
+
# i.mutation "1a2b3c4d5e6f7a8b",
|
|
18
|
+
# subject: "Person#adult?",
|
|
19
|
+
# reason: "equivalent: age is always an integer"
|
|
20
|
+
# end
|
|
12
21
|
#
|
|
13
22
|
# reason is mandatory; a file with a missing or blank reason does not load.
|
|
14
23
|
class IgnoreList
|
|
15
24
|
Entry = Data.define(:subject, :mutation, :reason)
|
|
16
25
|
|
|
26
|
+
# Collects the entries a saboteur_ignore.rb file declares.
|
|
27
|
+
class Collector
|
|
28
|
+
attr_reader :entries
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@entries = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mutation(digest = nil, subject: nil, reason: nil)
|
|
35
|
+
line = caller_locations(1, 1).first.lineno
|
|
36
|
+
values = { "subject" => subject, "mutation" => digest, "reason" => reason }
|
|
37
|
+
missing = values.select { |_, value| value.to_s.strip.empty? }.keys
|
|
38
|
+
raise ConfigError, "#{line}: entry is missing #{missing.join(', ')}" if missing.any?
|
|
39
|
+
|
|
40
|
+
@entries << Entry.new(
|
|
41
|
+
subject: subject.to_s.strip,
|
|
42
|
+
mutation: digest.to_s.strip,
|
|
43
|
+
reason: reason.to_s.strip
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
17
48
|
attr_reader :entries
|
|
18
49
|
|
|
50
|
+
# The collector is process-global on purpose: the ignore file calls the
|
|
51
|
+
# top-level Saboteur.ignore, which must reach the load in progress.
|
|
52
|
+
# Loads are therefore serialized - never load two ignore files at once.
|
|
19
53
|
def self.load(path)
|
|
20
54
|
return new([]) unless path && File.file?(path)
|
|
21
55
|
|
|
22
|
-
|
|
23
|
-
|
|
56
|
+
@collector = Collector.new
|
|
57
|
+
Kernel.load(path)
|
|
58
|
+
new(@collector.entries)
|
|
59
|
+
rescue ConfigError => e
|
|
60
|
+
raise ConfigError, "#{path}:#{e.message}"
|
|
61
|
+
rescue NoMethodError => e
|
|
62
|
+
raise ConfigError, unknown_directive_message(path, e) if e.receiver.is_a?(Collector)
|
|
63
|
+
|
|
64
|
+
# A typo'd call outside the collector: same treatment as a bare
|
|
65
|
+
# constant, or the raw NoMethodError points at our internals.
|
|
66
|
+
raise ConfigError, name_error_message(path, e)
|
|
67
|
+
rescue SyntaxError, LoadError => e
|
|
68
|
+
raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
|
|
69
|
+
rescue NameError => e
|
|
70
|
+
raise ConfigError, name_error_message(path, e)
|
|
71
|
+
ensure
|
|
72
|
+
@collector = nil
|
|
73
|
+
end
|
|
24
74
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
75
|
+
def self.unknown_directive_message(path, error)
|
|
76
|
+
line = error.backtrace_locations.find { |loc| loc.path == path }&.lineno
|
|
77
|
+
"#{path}:#{line}: unknown directive #{error.name.to_s.inspect} - " \
|
|
78
|
+
'declare entries with mutation "<digest>", subject:, reason:'
|
|
28
79
|
end
|
|
29
80
|
|
|
30
|
-
def self.
|
|
31
|
-
|
|
81
|
+
def self.name_error_message(path, error)
|
|
82
|
+
line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
|
|
83
|
+
"#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
|
|
84
|
+
"- this file loads before the app boots; use plain Ruby or ENV"
|
|
85
|
+
end
|
|
32
86
|
|
|
33
|
-
|
|
34
|
-
missing = values.select { |_, value| value.empty? }.keys
|
|
35
|
-
raise ConfigError, "#{path}: entry #{index + 1} is missing #{missing.join(', ')}" if missing.any?
|
|
87
|
+
private_class_method :unknown_directive_message, :name_error_message
|
|
36
88
|
|
|
37
|
-
|
|
89
|
+
# The Collector Saboteur.ignore hands to the file being loaded.
|
|
90
|
+
def self.current_collector
|
|
91
|
+
@collector
|
|
38
92
|
end
|
|
39
93
|
|
|
40
94
|
def initialize(entries)
|
|
@@ -42,6 +96,11 @@ module Saboteur
|
|
|
42
96
|
@index = entries.to_h { |entry| [[entry.subject, entry.mutation], entry] }
|
|
43
97
|
end
|
|
44
98
|
|
|
99
|
+
# True when this result is a survivor the list accepts.
|
|
100
|
+
def accepts?(result)
|
|
101
|
+
result.survived? && @index.key?([result.subject.id, result.digest])
|
|
102
|
+
end
|
|
103
|
+
|
|
45
104
|
# Marks matching survivors as ignored. Returns warnings for stale entries:
|
|
46
105
|
# entries whose subject was examined but whose mutation no longer exists.
|
|
47
106
|
def apply(results)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require_relative "mutation_source/operator_registry"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
# Only real Ruby comments can suppress edits, never text inside a string.
|
|
10
|
+
class InlineSuppression
|
|
11
|
+
Directive = Data.define(:line, :operators, :reason)
|
|
12
|
+
|
|
13
|
+
def initialize(file, source)
|
|
14
|
+
@file = file
|
|
15
|
+
@directives = Prism.parse(source).comments.filter_map { |comment| parse(comment) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Preserve ordinary comment/formatting skips, but treat directives as
|
|
19
|
+
# selection inputs. Relative lines distinguish which code they suppress.
|
|
20
|
+
def signature(subject)
|
|
21
|
+
@directives.select { |entry| subject.contains_line?(entry.line) }.map do |entry|
|
|
22
|
+
[entry.line - subject.start_line, entry.operators.sort, entry.reason]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reason(subject, mutation)
|
|
27
|
+
mutation.edits.each do |edit|
|
|
28
|
+
line = subject.line_of(edit.start_byte)
|
|
29
|
+
directive = @directives.find do |entry|
|
|
30
|
+
entry.line == line && (entry.operators.empty? || entry.operators.include?(mutation.operator))
|
|
31
|
+
end
|
|
32
|
+
return directive if directive
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse(comment)
|
|
40
|
+
text = comment.location.slice.sub(/\A#\s*/, "")
|
|
41
|
+
return nil unless text.start_with?("saboteur:disable-line")
|
|
42
|
+
|
|
43
|
+
line = comment.location.start_line
|
|
44
|
+
tokens = Shellwords.split(text)
|
|
45
|
+
fail_at(line, "expected saboteur:disable-line") unless tokens.shift == "saboteur:disable-line"
|
|
46
|
+
reasons, operators = tokens.partition { |token| token.start_with?("reason=") }
|
|
47
|
+
reason, = reasons
|
|
48
|
+
fail_at(line, "one nonblank reason=\"...\" is required") unless
|
|
49
|
+
reasons.size == 1 && !reason.delete_prefix("reason=").strip.empty?
|
|
50
|
+
validate_operators(operators, line)
|
|
51
|
+
Directive.new(line: line, operators: operators, reason: reason.delete_prefix("reason="))
|
|
52
|
+
rescue ArgumentError => e
|
|
53
|
+
fail_at(line, "invalid suppression: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_operators(operators, line)
|
|
57
|
+
unknown = operators - MutationSource::OperatorRegistry::DESCRIPTIONS.keys
|
|
58
|
+
fail_at(line, "unknown operators: #{unknown.join(', ')}") unless unknown.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fail_at(line, message)
|
|
62
|
+
raise ConfigError, "#{@file}:#{line}: #{message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# Writes the Rails starter files: config/saboteur.rb, bin/saboteur and a
|
|
8
|
+
# .gitignore entry. Plain Ruby so it runs and tests without Rails; the
|
|
9
|
+
# `rails generate saboteur:install` generator is a thin wrapper over it.
|
|
10
|
+
# Existing files are never overwritten unless force is set.
|
|
11
|
+
class Install
|
|
12
|
+
CONFIG = <<~RUBY
|
|
13
|
+
# saboteur configuration for a Rails + RSpec app. CLI flags override this file.
|
|
14
|
+
Saboteur.configure do |c|
|
|
15
|
+
c.include = ["app/**/*.rb"]
|
|
16
|
+
# Empty base classes: nothing useful to mutate.
|
|
17
|
+
c.exclude = [
|
|
18
|
+
"app/models/application_record.rb",
|
|
19
|
+
"app/controllers/application_controller.rb",
|
|
20
|
+
"app/jobs/application_job.rb",
|
|
21
|
+
"app/mailers/application_mailer.rb",
|
|
22
|
+
]
|
|
23
|
+
c.require = ["./config/environment"] # boots the app before specs load
|
|
24
|
+
c.rails = true # reconnect ActiveRecord after fork, per-worker databases
|
|
25
|
+
c.jobs = 4 # parallel workers; use 1 if your specs share external state
|
|
26
|
+
end
|
|
27
|
+
RUBY
|
|
28
|
+
|
|
29
|
+
BIN = <<~RUBY
|
|
30
|
+
#!/usr/bin/env ruby
|
|
31
|
+
# frozen_string_literal: true
|
|
32
|
+
|
|
33
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
34
|
+
require "bundler/setup"
|
|
35
|
+
load Gem.bin_path("saboteur", "saboteur")
|
|
36
|
+
RUBY
|
|
37
|
+
|
|
38
|
+
IGNORE_ENTRY = ".saboteur/"
|
|
39
|
+
|
|
40
|
+
GEMFILE_HINT = <<~TEXT
|
|
41
|
+
Add saboteur to your Gemfile if it is not there yet:
|
|
42
|
+
|
|
43
|
+
group :test do
|
|
44
|
+
gem "saboteur", require: false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Then run it with SimpleCov off for the run, for example:
|
|
48
|
+
|
|
49
|
+
bin/saboteur run --since origin/main
|
|
50
|
+
TEXT
|
|
51
|
+
|
|
52
|
+
Result = Data.define(:status, :path)
|
|
53
|
+
|
|
54
|
+
def initialize(root: Dir.pwd, force: false)
|
|
55
|
+
@root = File.expand_path(root)
|
|
56
|
+
@force = force
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the list of Results (:created, :overwritten, :skipped, :appended, :unchanged).
|
|
60
|
+
def call
|
|
61
|
+
[write("config/saboteur.rb", CONFIG), write("bin/saboteur", BIN, mode: 0o755), gitignore]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Printed after install; the Gemfile is never edited.
|
|
65
|
+
def gemfile_hint
|
|
66
|
+
gemfile = File.join(@root, "Gemfile")
|
|
67
|
+
return nil if File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']saboteur["']/)
|
|
68
|
+
|
|
69
|
+
GEMFILE_HINT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Never writes or chmods through a symlink: not the file itself, and not a
|
|
75
|
+
# symlinked directory above it (bin -> elsewhere). Without force the file is
|
|
76
|
+
# skipped; with force the link is removed and replaced by a real file or
|
|
77
|
+
# directory inside the project, and the old target is left alone.
|
|
78
|
+
def write(relative, content, mode: nil)
|
|
79
|
+
path = File.join(@root, relative)
|
|
80
|
+
link = symlink_component(relative)
|
|
81
|
+
existed = present?(path)
|
|
82
|
+
return Result.new(:skipped, relative) if (existed || link) && !@force
|
|
83
|
+
|
|
84
|
+
File.delete(link) if link
|
|
85
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
86
|
+
File.write(path, content)
|
|
87
|
+
File.chmod(mode, path) if mode
|
|
88
|
+
Result.new(existed ? :overwritten : :created, relative)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The first symlink on the way from the project root to the file, if any.
|
|
92
|
+
def symlink_component(relative)
|
|
93
|
+
current = @root
|
|
94
|
+
relative.split("/").each do |part|
|
|
95
|
+
current = File.join(current, part)
|
|
96
|
+
return current if File.symlink?(current)
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A dangling symlink does not "exist" but is not free to write either.
|
|
102
|
+
def present?(path)
|
|
103
|
+
File.exist?(path) || File.symlink?(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gitignore
|
|
107
|
+
path = File.join(@root, ".gitignore")
|
|
108
|
+
link = symlink_component(".gitignore")
|
|
109
|
+
return Result.new(:skipped, ".gitignore") if link && !@force
|
|
110
|
+
|
|
111
|
+
current = File.file?(path) ? File.read(path) : ""
|
|
112
|
+
listed = listed?(current)
|
|
113
|
+
return Result.new(:unchanged, ".gitignore") if listed && !link
|
|
114
|
+
|
|
115
|
+
File.delete(link) if link
|
|
116
|
+
File.write(path, "#{current}#{missing_entry(current, listed)}")
|
|
117
|
+
Result.new(link ? :overwritten : :appended, ".gitignore")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def listed?(text)
|
|
121
|
+
text.lines.map(&:strip).include?(IGNORE_ENTRY)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What to append so .saboteur/ is on its own line; nothing if already there.
|
|
125
|
+
def missing_entry(current, listed)
|
|
126
|
+
return "" if listed
|
|
127
|
+
|
|
128
|
+
separator = current.empty? || current.end_with?("\n") ? "" : "\n"
|
|
129
|
+
"#{separator}#{IGNORE_ENTRY}\n"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Integration
|
|
6
|
+
# Refuses a suite that a focus filter has narrowed without the user asking
|
|
7
|
+
# for it. With `filter_run_when_matching :focus`, one stray `fit` makes
|
|
8
|
+
# RSpec skip every other example. The baseline stays green and the score
|
|
9
|
+
# then describes only the focused examples, with no sign that the rest
|
|
10
|
+
# never ran.
|
|
11
|
+
#
|
|
12
|
+
# The test is a difference of two selections, both made by RSpec's own
|
|
13
|
+
# FilterManager#prune: the examples that run now, and the examples that
|
|
14
|
+
# would run if the stray focus rule were absent. Everything else RSpec
|
|
15
|
+
# decides stays the same in both, so it never counts as hidden: example
|
|
16
|
+
# selectors (`spec/a_spec.rb:12`) and their file-scoped priority,
|
|
17
|
+
# `--tag` filters named in rspec_args, exclusions, and the :if / :unless
|
|
18
|
+
# pruning of conditional examples.
|
|
19
|
+
class FocusGuard
|
|
20
|
+
LOCATION_KEYS = %i[locations ids].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(world:, explicit_filter_keys:)
|
|
23
|
+
@world = world
|
|
24
|
+
@explicit_filter_keys = explicit_filter_keys
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Raises ConfigError naming the focused examples when the focus filter
|
|
28
|
+
# hides examples that would otherwise run.
|
|
29
|
+
def check!
|
|
30
|
+
return if stray_rules.empty?
|
|
31
|
+
return if hidden_examples.empty?
|
|
32
|
+
|
|
33
|
+
raise ConfigError, message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :world
|
|
39
|
+
|
|
40
|
+
def filter_manager
|
|
41
|
+
world.filter_manager
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Inclusion rules the suite added itself, such as the :focus rule that
|
|
45
|
+
# `filter_run_when_matching :focus` installs once a fit exists.
|
|
46
|
+
def stray_rules
|
|
47
|
+
@stray_rules ||= begin
|
|
48
|
+
rules = filter_manager.inclusions.rules
|
|
49
|
+
rules.slice(*(rules.keys - LOCATION_KEYS - @explicit_filter_keys))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def running_examples
|
|
54
|
+
@running_examples ||= filter_manager.prune(world.all_examples)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The selection RSpec would make without the stray rules.
|
|
58
|
+
def examples_without_stray_focus
|
|
59
|
+
manager = ::RSpec::Core::FilterManager.new
|
|
60
|
+
manager.exclusions.rules.replace(filter_manager.exclusions.rules)
|
|
61
|
+
manager.inclusions.rules.replace(filter_manager.inclusions.rules.except(*stray_rules.keys))
|
|
62
|
+
manager.prune(world.all_examples)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def hidden_examples
|
|
66
|
+
@hidden_examples ||= examples_without_stray_focus - running_examples
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def focused_examples
|
|
70
|
+
focus = ::RSpec::Core::FilterRules.new(stray_rules)
|
|
71
|
+
running_examples.select { |example| focus.include_example?(example) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def message
|
|
75
|
+
total = hidden_examples.size + running_examples.size
|
|
76
|
+
locations = focused_examples.map(&:location).uniq.first(5).join(", ")
|
|
77
|
+
"a focus filter (#{::RSpec::Core::FilterRules.new(stray_rules).description}) narrowed the suite: " \
|
|
78
|
+
"#{hidden_examples.size} of #{total} examples would not run, so the score would cover only the " \
|
|
79
|
+
"ones that do (#{locations}). Remove the focus, or pass --allow-focus to run the narrowed suite anyway."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|