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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. 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. With --threshold: the score is at or above " \
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 .saboteur.yml. A replay --id that matches no " \
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 every executed mutant errored. With --threshold, " \
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 .saboteur.yml.",
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, checks runtime, coverage and boot files, then runs your " \
63
- "suite twice in the same order with one seed and reports whether both runs passed. " \
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
- ["Use another config file and seed", "saboteur doctor --config ci.yml --seed 7"]]
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
- [".saboteur.yml", "Settings. Command line options override it."],
91
- [".saboteur-ignore.yml", "Accepted survivors: entries with subject, mutation (digest) and a required reason."],
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
- "#{title}\n#{rows.join("\n")}\n"
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| " #{key}: #{describe_default(value)}" }
177
- intro = "Every key with its default value, as YAML. Unknown keys are an error."
178
- "CONFIG FILE (.saboteur.yml)\n#{intro}\n#{rows.join("\n")}\n"
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
- # Valid YAML (JSON flow style), so a copied line keeps the default's meaning.
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
- JSON.generate(value)
223
+ value.inspect
184
224
  end
185
225
 
186
226
  def files_section
@@ -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
- # Survivors the team has accepted. YAML list of entries:
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
- # - subject: "Person#adult?"
10
- # mutation: "1a2b3c4d5e6f7a8b" # digest shown next to each survivor
11
- # reason: "equivalent: age is always an integer"
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
- data = YAML.safe_load_file(path) || []
23
- raise ConfigError, "#{path}: expected a list of entries" unless data.is_a?(Array)
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
- new(data.each_with_index.map { |raw, index| parse_entry(raw, path, index) })
26
- rescue Psych::SyntaxError => e
27
- raise ConfigError, "#{path}: #{e.message}"
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.parse_entry(raw, path, index)
31
- raise ConfigError, "#{path}: entry #{index + 1} is not a mapping" unless raw.is_a?(Hash)
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
- values = %w[subject mutation reason].to_h { |key| [key, raw[key].to_s.strip] }
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
- Entry.new(subject: values["subject"], mutation: values["mutation"], reason: values["reason"])
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
@@ -77,4 +77,5 @@ module Saboteur
77
77
  end
78
78
  end
79
79
 
80
+ require_relative "focus_guard"
80
81
  require_relative "rspec"
@@ -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