saboteur 0.8.0 → 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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +44 -6
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +116 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +2 -1
  7. data/lib/saboteur/cli/agent_provenance.rb +59 -0
  8. data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
  9. data/lib/saboteur/cli/doctor_command.rb +41 -0
  10. data/lib/saboteur/cli/error_cause.rb +40 -0
  11. data/lib/saboteur/cli/execution_options.rb +46 -0
  12. data/lib/saboteur/cli/exit_status.rb +12 -14
  13. data/lib/saboteur/cli/explicit_file_options.rb +21 -0
  14. data/lib/saboteur/cli/explicit_sources.rb +46 -0
  15. data/lib/saboteur/cli/failure_output.rb +35 -0
  16. data/lib/saboteur/cli/help_command.rb +23 -0
  17. data/lib/saboteur/cli/matrix_option.rb +17 -0
  18. data/lib/saboteur/cli/output_options.rb +27 -0
  19. data/lib/saboteur/cli/presets.rb +41 -1
  20. data/lib/saboteur/cli/preview_command.rb +26 -0
  21. data/lib/saboteur/cli/run_budget_options.rb +6 -1
  22. data/lib/saboteur/cli/run_command.rb +33 -0
  23. data/lib/saboteur/cli/run_output.rb +38 -0
  24. data/lib/saboteur/cli/run_reporting.rb +6 -5
  25. data/lib/saboteur/cli/run_validation.rb +54 -0
  26. data/lib/saboteur/cli/source_set.rb +0 -18
  27. data/lib/saboteur/cli.rb +29 -305
  28. data/lib/saboteur/config.rb +76 -7
  29. data/lib/saboteur/config_file.rb +12 -8
  30. data/lib/saboteur/constant_subject_finder.rb +3 -0
  31. data/lib/saboteur/custom_map.rb +24 -5
  32. data/lib/saboteur/deep_source.rb +49 -0
  33. data/lib/saboteur/dirty_tree_warning.rb +6 -4
  34. data/lib/saboteur/doctor.rb +10 -0
  35. data/lib/saboteur/engine/baseline_guard.rb +118 -15
  36. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  37. data/lib/saboteur/engine/boot.rb +11 -3
  38. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  39. data/lib/saboteur/engine/candidate_selection.rb +16 -5
  40. data/lib/saboteur/engine/candidates.rb +4 -1
  41. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  42. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  43. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  44. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  45. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  46. data/lib/saboteur/engine/coverage_store.rb +296 -21
  47. data/lib/saboteur/engine/diagnostics.rb +24 -7
  48. data/lib/saboteur/engine/distribution.rb +2 -2
  49. data/lib/saboteur/engine/gated_run.rb +4 -1
  50. data/lib/saboteur/engine/load_coverage.rb +72 -8
  51. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  52. data/lib/saboteur/engine/partial_merge.rb +40 -0
  53. data/lib/saboteur/engine/pipelines.rb +11 -3
  54. data/lib/saboteur/engine/reference_specs.rb +71 -0
  55. data/lib/saboteur/engine/result_cache.rb +68 -5
  56. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  57. data/lib/saboteur/engine/run_budget.rb +10 -7
  58. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  59. data/lib/saboteur/engine/sandbox_policy.rb +26 -5
  60. data/lib/saboteur/engine/scheduler.rb +33 -25
  61. data/lib/saboteur/engine/selection_announcement.rb +9 -3
  62. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  63. data/lib/saboteur/engine/shadow_run.rb +38 -0
  64. data/lib/saboteur/engine/subject_selection.rb +144 -64
  65. data/lib/saboteur/engine/suite_partition.rb +22 -0
  66. data/lib/saboteur/engine/test_guess.rb +186 -32
  67. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  68. data/lib/saboteur/engine.rb +44 -11
  69. data/lib/saboteur/environment_hints.rb +55 -0
  70. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  71. data/lib/saboteur/execution/external_runner.rb +123 -1
  72. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  73. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  74. data/lib/saboteur/execution/marker_sweep.rb +2 -1
  75. data/lib/saboteur/execution/process_table.rb +7 -3
  76. data/lib/saboteur/execution/reevaluator.rb +7 -0
  77. data/lib/saboteur/execution/sandbox.rb +39 -20
  78. data/lib/saboteur/execution/seatbelt.rb +10 -3
  79. data/lib/saboteur/execution/supervisor.rb +42 -8
  80. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  81. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  82. data/lib/saboteur/execution/worker_pool.rb +48 -4
  83. data/lib/saboteur/frameworks/rails.rb +2 -5
  84. data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
  85. data/lib/saboteur/gates.rb +121 -28
  86. data/lib/saboteur/install.rb +6 -1
  87. data/lib/saboteur/integration/base.rb +19 -2
  88. data/lib/saboteur/integration/rspec.rb +145 -18
  89. data/lib/saboteur/line_hits.rb +81 -0
  90. data/lib/saboteur/method_calls.rb +91 -0
  91. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  92. data/lib/saboteur/mutation_source/builtin.rb +33 -21
  93. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  94. data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
  95. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  96. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  97. data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
  98. data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
  99. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  100. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  101. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  102. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  103. data/lib/saboteur/preview.rb +13 -0
  104. data/lib/saboteur/refinement_activations.rb +22 -10
  105. data/lib/saboteur/reporter/agent.rb +2 -2
  106. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  107. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  108. data/lib/saboteur/reporter/review_comments.rb +151 -0
  109. data/lib/saboteur/reporter/safe_out.rb +32 -0
  110. data/lib/saboteur/reporter/stryker.rb +8 -6
  111. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  112. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  113. data/lib/saboteur/reporter/terminal.rb +45 -5
  114. data/lib/saboteur/result.rb +14 -4
  115. data/lib/saboteur/selection/coverage_map.rb +150 -9
  116. data/lib/saboteur/selection/external_test_map.rb +4 -0
  117. data/lib/saboteur/selection/git_diff.rb +24 -2
  118. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  119. data/lib/saboteur/selection/recorder.rb +52 -22
  120. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  121. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  122. data/lib/saboteur/subject.rb +16 -0
  123. data/lib/saboteur/subject_finder.rb +91 -6
  124. data/lib/saboteur/text.rb +31 -0
  125. data/lib/saboteur/version.rb +1 -1
  126. data/lib/saboteur/working_tree.rb +7 -2
  127. data/lib/saboteur.rb +9 -0
  128. metadata +48 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e6b6d8e50fff66a62fceb86427129fb8223251089a56a538265cc2ca0437559
4
- data.tar.gz: 5ccca5b91560563ef6b6c2da93dc9191498074b714793e8586cbfa57dfbecf66
3
+ metadata.gz: 30c6af46486b9fc1138b568c45934cc895f786a79ea5215607f493a5a7d5af68
4
+ data.tar.gz: a7a8038afc6166347fac807028cf7f45acecbb7e4c27e32f8917c4167b805bac
5
5
  SHA512:
6
- metadata.gz: 1adf971a3548189acb63ee19e382d98f91cc9c3b9980bdc9d92e903754ab96d1aebb706c95cc8b7da4ee3a4eb851311b94b35f3f599213aa7e1e787adceff282
7
- data.tar.gz: b11f8aba3ffa20e5f97d68ef2422eca609c6277e0d8dc408d30a70851342d1f64c97a563224a26a6f0d1652321e200242230bc85e5e62cd02428b7a8e21e24b5
6
+ metadata.gz: 66817191adf235c1fb6adb41d3218704557d2d3305fab299f17cc38bb3b392733f0a52615d84aadb5e4fd940046db9336de9efa178b205d58cc8e1c61374c25a
7
+ data.tar.gz: 132649f6b81d5ee312341a032df36b890af481704deee6c4b5cd4a013736c857ddc7887b27318409a1db05ea2621cbc8b2791ff4d0c40d3509810a6e13f7a405
data/CHANGELOG.md CHANGED
@@ -6,6 +6,221 @@ 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
+
109
+ ## [0.8.1] - 2026-10-18
110
+
111
+ ### Added
112
+
113
+ - Positional call-argument swap operator (#653): swaps adjacent positional
114
+ arguments at call sites, catching specs that do not pin argument order.
115
+ - After the first timed baseline pass, saboteur projects the remaining
116
+ baseline+coverage cost against `--max-duration` and warns up front when
117
+ the budget barely fits or is exceeded, naming how much time mutants get
118
+ and the remedies (#637). A `--max-duration` run that ends with exit 4
119
+ and 0 mutants now headlines "no mutants ran", and the flag's help states
120
+ what the clock covers: it starts at engine entry and includes the
121
+ baseline and coverage phases.
122
+ - fiddle is no longer a hard dependency: it loads lazily through one door
123
+ that refuses with every working fix named when it is missing, and the
124
+ sandbox layers fail closed on refusal (#639).
125
+
126
+ ### Fixed
127
+
128
+ - The DCO checks no longer disagree: CI's check-dco, preflight, and the
129
+ panel gate all require the Signed-off-by line to be a real git trailer
130
+ after a blank line (#646).
131
+ - When a changed file matches no spec by id hint, saboteur names the exact
132
+ ids it looked for (`warn_unmatched_whole_file`, `--since` scoped) (#635).
133
+ - `saboteur pr --since` on a spec-only or docs-only diff no longer warns
134
+ that "the whole suite runs" - it announces there is nothing to test,
135
+ matching the zero-subject run that follows (#607).
136
+ - On macOS the degraded-sandbox message no longer claims the kernel "lacks
137
+ Landlock (needs Linux 6.2+)"; it says macOS has no Landlock and points
138
+ at `c.sandbox = "seatbelt"` (#667).
139
+ - The preset banner shows the effective flags, including explicit
140
+ `--since` refs and flag=value spellings (#666).
141
+ - Running saboteur without rspec-core in the bundle (for example a
142
+ Minitest project) fails as a usage error (exit 2) naming docs/minitest.md,
143
+ instead of an internal error pointing at the RSpec-only `c.integration`
144
+ remedy (#610). A baseline that fails with NoMethodError in a suite whose
145
+ spec_helper requires minitest now warns the suite looks like Minitest
146
+ running under RSpec, with the symptom and count (#626).
147
+ - Test-file guessing looks for specs below a flattened namespace
148
+ (rubocop-ast style layouts) (#586), and when a layout strips directories
149
+ between source and spec trees it picks the unique spec by parent
150
+ directory and basename instead of failing or guessing wrong (#600).
151
+ - A `--subject` pattern that names an attr_reader, attr_writer or
152
+ attr_accessor method no longer says "matches no subject"; the method
153
+ exists but has no body to mutate, and the message says so (#373).
154
+ - Load errors in the target app's boot are reported with the tool's own
155
+ frames filtered out, with `--verbose` restoring the full backtrace (#608).
156
+ - If a spec file loaded Ruby code before saboteur started coverage,
157
+ whole-suite runs no longer report "5 subjects, 0 mutants"; saboteur
158
+ names up to 5 preloaded files with cause and remedy and exits 3. With
159
+ `--test` it warns and continues (#686).
160
+ - An empty or docs-only `--since` range exits 0 with a "nothing to test"
161
+ notice instead of failing (#659), and a run no longer floods stderr with
162
+ Ruby's "possibly useless use of nil in void context" compile warnings
163
+ when the host suite enables warnings (#656).
164
+ - Running saboteur in a project with no RSpec suite stops with a single
165
+ line naming the problem and pointing to docs/minitest.md, instead of one
166
+ line per source file; specs outside spec/ fall back to the per-file stop
167
+ with `--test`/`--all`. Exit code unchanged (2) (#606).
168
+ - Whole-file targets no longer error "already initialized constant" in
169
+ strict-warning suites when the app lazily requires the same file (#632).
170
+ - Doctor's macOS degraded-sandbox output names the way out:
171
+ `c.sandbox = "seatbelt"` with the guide link, or `c.sandbox = false` (#667).
172
+ - Load-time vetting no longer rejects `class << self` or `/re/.freeze`,
173
+ and the constant-skip warning cites a one-line excerpt plus line instead
174
+ of whole class bodies (#664). Vetting clears Struct/Data factories,
175
+ public_constant/private_constant, and lambda literals so those files
176
+ keep their mutation subjects (#537).
177
+ - A spec load that quits because the suite auto-starts SimpleCov/SingleCov
178
+ names the suspect and both guard styles, instead of an opaque "No
179
+ examples found" (#645).
180
+ - A sqlite :memory: database with a schema but zero rows hints at
181
+ require-time seed data lost across the fork, with the
182
+ before(:suite)/after_fork remedy and the `c.hooks` wiring (#594).
183
+ - The early-stopped summary headline reads selected versus attempted, for
184
+ example "57 mutants selected, 4 attempted" (#582).
185
+ - The dirty-tree warning headline says "uncommitted or untracked change(s)"
186
+ so a tree of only scratch files is not read as edits (#604).
187
+ - Custom-map load errors name unsupported template syntax before glob
188
+ validation, literal map paths are preserved, and mixing numbered and
189
+ named map templates is rejected at load with a clear error (#642).
190
+ - The sqlite in-memory database fork-loss hint includes the exact
191
+ `c.hooks` configuration needed to load the suggested after-fork hooks
192
+ file (#593).
193
+ - A subject the `--max-duration` budget stops mid-list no longer drops the
194
+ kills it already measured: the truncated subject's completed kills are
195
+ confirmed uncapped (#588).
196
+ - Shell-exported RUBYLIB entries that bundler's scrub drops are restored
197
+ (#584), `spec/` is on the load path for non-Rails boots too (#615), and
198
+ RSpec core loads before configured boot helpers so non-Rails helpers can
199
+ call RSpec.configure (#616).
200
+ - `--test-command` warns when one ruby is handed several test files: ruby
201
+ executes only the first, so the rest never ran and their mutants
202
+ silently survived (#611).
203
+ - Dry-run excludes statically unsupported subjects, such as methods
204
+ defined inside blocks, from planned mutant totals and reports the skip
205
+ reason (#651).
206
+ - Non-String integration assignments fail with a file-and-line
207
+ configuration error before fingerprinting or boot (#614).
208
+ - Baseline failures outside examples say so and show the log tail instead
209
+ of an empty failed-example list (#581).
210
+
211
+ ### Documentation
212
+
213
+ - The flaky-tests and survivors guides catch up with 0.8.0 (#683), and
214
+ design.md explains the load-time vetting safelist for constant replay
215
+ under `--since`, including what it cannot account for (#537).
216
+ - Docs drift: the pinned saboteur-vs-mutineer benchmark report names the
217
+ version it measured (0.1.0), and the compare page scopes its kill
218
+ re-check claim to fresh kills (#657).
219
+ - The README quick start covers the inference-miss exit 2 and its
220
+ recovery, the install block leads with Bundler, the quick-start anchor
221
+ matches its heading, and a no-warranty disclaimer joins the license
222
+ section (#658, #657, #599).
223
+
9
224
  ## [0.8.0] - 2026-10-18
10
225
 
11
226
  ### Added
data/README.md CHANGED
@@ -21,26 +21,44 @@ code, so a broken suite never counts as a kill.
21
21
  **Docs, benchmarks and guides live on the site:
22
22
  [vamsipavanmahesh.github.io/saboteur](https://vamsipavanmahesh.github.io/saboteur/)**
23
23
 
24
- <a id="60-second-quick-start"></a>
24
+ <a id="30-second-quick-start"></a>
25
25
  ## 30-second quick start
26
26
 
27
27
  You need Ruby 3.3 or newer, RSpec, and Linux or macOS with `fork`.
28
28
 
29
- ```sh
30
- # Install the gem:
31
- gem install saboteur
29
+ Prerequisite: the sandbox layers use fiddle, which ships with Ruby
30
+ (a default gem on Ruby 3.x). On Ruby 4.0+, where fiddle is a bundled
31
+ gem, add `gem "fiddle"` to your Gemfile. If saboteur refuses a run
32
+ because fiddle cannot load: reinstall Ruby, run `gem install fiddle`,
33
+ or install libffi headers (libffi-dev on Debian/Ubuntu, libffi-devel
34
+ on Fedora/RHEL) and reinstall fiddle. libffi headers are required only
35
+ when Ruby's shipped fiddle is missing or must be installed standalone.
32
36
 
33
- # Or add it to the Gemfile:
37
+ ```sh
38
+ # Add it to your project's Gemfile:
34
39
  bundle add saboteur --group test
35
40
 
36
- # Either way:
41
+ # Then:
37
42
  bundle exec saboteur run
38
43
  ```
39
44
 
45
+ Outside a Bundler project, `gem install saboteur` gives the same
46
+ executable - run it there as plain `saboteur run`, without
47
+ `bundle exec`.
48
+
40
49
  The first run builds a coverage map of which tests touch which code. The
41
50
  map is cached and reused while your code and tests stay unchanged, so
42
51
  later runs start fast.
43
52
 
53
+ If the first run stops with exit 2 and `no spec found for ...`, saboteur
54
+ could not map a source file to its spec: it matches by path first, then
55
+ by parent directory and basename when the layout strips directories,
56
+ and it refuses to guess wrong. Point it at the specs yourself - set
57
+ `test_selection = :all` in `config/saboteur.rb` to run the whole suite
58
+ against each mutant, pass `--test spec/...` to name the specs, or
59
+ declare a custom map
60
+ ([monorepos and the custom map](docs/guides/monorepo-and-custom-map.md)).
61
+
44
62
  On a branch, `bundle exec saboteur pr` mutates only the methods you
45
63
  changed relative to `main`, with one baseline pass and a ten-minute
46
64
  budget. If the budget stops the run, it exits 4 and prints `partial: X
@@ -109,6 +127,16 @@ the macro itself `ran at load` instead of letting its mutants survive.
109
127
  before a kill is rejected, so its mutants are still scored.
110
128
  - **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
111
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.
112
140
  - **A plan before the run.** `saboteur run --dry-run --format json` prints
113
141
  the versioned [mutant inventory](docs/dry-run-json.md) - every planned
114
142
  mutant with its stable digest and edits - without booting the suite.
@@ -247,6 +275,16 @@ on the site and [docs/benchmark](docs/benchmark/) in this repo.
247
275
  - [Contributing](CONTRIBUTING.md) - every commit needs a DCO `Signed-off-by` line
248
276
  - [Design notes](docs/design.md)
249
277
 
278
+ ## Disclaimer
279
+
280
+ saboteur is provided "as is", without warranty of any kind, express or
281
+ implied. In no event shall the authors or copyright holders be liable
282
+ for any claim, damages or other liability arising from its use -
283
+ including damage caused by mutating your code or by running unusual
284
+ DSLs and metaprogramming-heavy code. Mutation testing runs your test
285
+ suite against deliberately broken code; run it on a clean, committed
286
+ tree or a CI checkout. The full text is in [LICENSE](LICENSE).
287
+
250
288
  ## License
251
289
 
252
290
  [MIT](LICENSE).
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, methods: true, branches: true) if Coverage.state == :idle
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
- Coverage.resume if ARGV.first == "run" && Coverage.state == :suspended
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
 
@@ -0,0 +1,116 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "deep_source"
6
+ require_relative "subject"
7
+
8
+ module Saboteur
9
+ # Finds the methods attribute macros generate - attr_reader, attr_writer,
10
+ # attr_accessor - so a --subject pattern naming one can be answered with
11
+ # "defined by attr_reader, no body to mutate" instead of the
12
+ # pattern-format hint (#373). The generated method has no body saboteur
13
+ # can mutate, so it is never a mutation subject. Only a receiverless
14
+ # call with literal symbol or string names defines anything nameable;
15
+ # like SubjectFinder, a macro inside a block binds to an unknowable
16
+ # self and is skipped.
17
+ class AttributeFinder
18
+ # One generated method: id follows Subject#id ("Owner#name",
19
+ # "Owner.name", "Owner#name="), macro is the call that made it.
20
+ Found = Data.define(:id, :macro)
21
+
22
+ MACROS = %i[attr_reader attr_writer attr_accessor].freeze
23
+
24
+ def call(file)
25
+ result = Prism.parse(File.binread(file), filepath: file)
26
+ return [] if result.failure?
27
+
28
+ DeepSource.check!(result.value, file)
29
+
30
+ found = []
31
+ walk(result.value, [], found)
32
+ found
33
+ end
34
+
35
+ private
36
+
37
+ def walk(node, nesting, out, in_block: false)
38
+ return if node.nil?
39
+
40
+ case node
41
+ when Prism::BlockNode, Prism::LambdaNode
42
+ walk(node.body, nesting, out, in_block: true)
43
+ # A keyword class or module re-enters the lexical scope: a macro
44
+ # inside binds on the class even when the class sits in a block
45
+ # (mirrors ConstantSubjectFinder).
46
+ when Prism::ModuleNode
47
+ walk(node.body, nesting + [Scope.new(kind: :module, path: node.constant_path.slice)], out)
48
+ when Prism::ClassNode
49
+ walk(node.body, nesting + [Scope.new(kind: :class, path: node.constant_path.slice)], out)
50
+ when Prism::SingletonClassNode
51
+ walk_singleton_class(node, nesting, out)
52
+ when Prism::CallNode
53
+ record_macro(node, nesting, out, in_block: in_block)
54
+ node.compact_child_nodes.each { |child| walk(child, nesting, out, in_block: in_block) }
55
+ else
56
+ node.compact_child_nodes.each { |child| walk(child, nesting, out, in_block: in_block) }
57
+ end
58
+ end
59
+
60
+ # `class << self`: attribute macros inside define singleton methods on
61
+ # the enclosing class (mirrors SubjectFinder's singleton defs).
62
+ def walk_singleton_class(node, nesting, out)
63
+ return unless node.expression.is_a?(Prism::SelfNode)
64
+ return if nesting.empty? || nesting.last.sclass?
65
+
66
+ walk(node.body, nesting + [Scope.new(kind: :sclass, path: "self")], out)
67
+ end
68
+
69
+ def record_macro(node, nesting, out, in_block:)
70
+ return unless MACROS.include?(node.name) && node.receiver.nil? && node.block.nil?
71
+ return if in_block
72
+
73
+ owner = owner_path(nesting)
74
+ return unless owner
75
+
76
+ separator = nesting.last&.sclass? ? "." : "#"
77
+ node.arguments&.arguments.to_a.each do |argument|
78
+ name = macro_name(argument)
79
+ next unless name
80
+
81
+ generated_names(node.name, name).each do |method_name|
82
+ out << Found.new(id: "#{owner}#{separator}#{method_name}", macro: node.name)
83
+ end
84
+ end
85
+ end
86
+
87
+ # The methods one macro call generates per named attribute: a reader, a
88
+ # writer, or both for attr_accessor.
89
+ def generated_names(macro, name)
90
+ case macro
91
+ when :attr_reader then [name]
92
+ when :attr_writer then ["#{name}="]
93
+ else [name, "#{name}="]
94
+ end
95
+ end
96
+
97
+ def macro_name(argument)
98
+ argument.unescaped if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
99
+ end
100
+
101
+ # Lexical path of the enclosing class/module; `class ::Foo` restarts at
102
+ # the top. A `class << self` scope changes the separator, not the path.
103
+ def owner_path(nesting)
104
+ path = +""
105
+ nesting.each do |scope|
106
+ next if scope.sclass?
107
+
108
+ segment = scope.path
109
+ path.clear if segment.start_with?("::")
110
+ path << "::" unless path.empty?
111
+ path << segment.delete_prefix("::")
112
+ end
113
+ path.empty? ? nil : path
114
+ end
115
+ end
116
+ end
@@ -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 = %w[cache_dir json_report html_report score_history].freeze
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