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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +44 -6
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +116 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/presets.rb +41 -1
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +6 -1
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli/source_set.rb +0 -18
- data/lib/saboteur/cli.rb +29 -305
- data/lib/saboteur/config.rb +76 -7
- data/lib/saboteur/config_file.rb +12 -8
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +24 -5
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +6 -4
- data/lib/saboteur/doctor.rb +10 -0
- data/lib/saboteur/engine/baseline_guard.rb +118 -15
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +16 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +296 -21
- data/lib/saboteur/engine/diagnostics.rb +24 -7
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +4 -1
- data/lib/saboteur/engine/load_coverage.rb +72 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +11 -3
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +10 -7
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +26 -5
- data/lib/saboteur/engine/scheduler.rb +33 -25
- data/lib/saboteur/engine/selection_announcement.rb +9 -3
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +144 -64
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +186 -32
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +44 -11
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +123 -1
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/marker_sweep.rb +2 -1
- data/lib/saboteur/execution/process_table.rb +7 -3
- data/lib/saboteur/execution/reevaluator.rb +7 -0
- data/lib/saboteur/execution/sandbox.rb +39 -20
- data/lib/saboteur/execution/seatbelt.rb +10 -3
- data/lib/saboteur/execution/supervisor.rb +42 -8
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +145 -18
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +33 -21
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +13 -0
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +45 -5
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +150 -9
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +9 -0
- metadata +48 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30c6af46486b9fc1138b568c45934cc895f786a79ea5215607f493a5a7d5af68
|
|
4
|
+
data.tar.gz: a7a8038afc6166347fac807028cf7f45acecbb7e4c27e32f8917c4167b805bac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66817191adf235c1fb6adb41d3218704557d2d3305fab299f17cc38bb3b392733f0a52615d84aadb5e4fd940046db9336de9efa178b205d58cc8e1c61374c25a
|
|
7
|
+
data.tar.gz: 132649f6b81d5ee312341a032df36b890af481704deee6c4b5cd4a013736c857ddc7887b27318409a1db05ea2621cbc8b2791ff4d0c40d3509810a6e13f7a405
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,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="
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
gem
|
|
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
|
-
|
|
37
|
+
```sh
|
|
38
|
+
# Add it to your project's Gemfile:
|
|
34
39
|
bundle add saboteur --group test
|
|
35
40
|
|
|
36
|
-
#
|
|
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,
|
|
14
|
+
Coverage.setup(lines: true, branches: true) if Coverage.state == :idle
|
|
15
15
|
|
|
16
16
|
require "saboteur/crash_report"
|
|
17
17
|
rescue LoadError, StandardError => e
|
|
@@ -27,7 +27,11 @@ end
|
|
|
27
27
|
# ones. The engine suspends counting right after boot and reads the
|
|
28
28
|
# counters as the map's load lines (a forked child would not inherit them).
|
|
29
29
|
begin
|
|
30
|
-
|
|
30
|
+
if ARGV.first == "run" && Coverage.state == :suspended
|
|
31
|
+
require "saboteur/method_calls"
|
|
32
|
+
Saboteur::MethodCalls.boot
|
|
33
|
+
Coverage.resume
|
|
34
|
+
end
|
|
31
35
|
|
|
32
36
|
require "saboteur/cli"
|
|
33
37
|
|
|
@@ -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 =
|
|
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
|