mutineer 0.8.0 → 0.9.1
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 +36 -6
- data/README.md +6 -4
- data/lib/mutineer/cli.rb +31 -20
- data/lib/mutineer/config.rb +3 -2
- data/lib/mutineer/coverage_map.rb +21 -0
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/reporter.rb +114 -0
- data/lib/mutineer/runner.rb +16 -4
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +18 -9
- data/lib/mutineer.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d61c927b33961995a0d38b691cdf0b34a6e9041213980c0cd7a3a01f457f875
|
|
4
|
+
data.tar.gz: 8e4f32e923344837ab664805a3099a9e67d405bbe7b63d83986d1af0b488ead5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ea750d511b52ef0f9d320c39ef0b4a3b51189116c09811b3bf4f2d5752b026d1ddc62411387a261f6f60ba73e23e2b76af1e553a745bd63f9a3d56a0cd068cb
|
|
7
|
+
data.tar.gz: 6dc10950ea34998e459e5007095bd43df87a633a4ca18e19afee1812a8fed154e086f3ae3a7b808bd84ba93336bb61208195d001efd846fea367502a23167ef2
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.9.1] - 2026-07-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Per-method uncapturable granularity** (#25) — the `:uncapturable` taint was
|
|
11
|
+
whole-file, so a method reachable only by a *failed* capture in an otherwise-
|
|
12
|
+
covered file was mislabeled `no_coverage`. It's now attributed per method (by
|
|
13
|
+
the method's body coverage), so only the affected method is tainted. Fully-
|
|
14
|
+
failed files are unchanged.
|
|
15
|
+
|
|
16
|
+
## [0.9.0] - 2026-06-30
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`--fail-fast`** (#21) — stop at the first surviving mutant; in-flight workers
|
|
20
|
+
drain, the rest are skipped. Fast red signal for PR gates.
|
|
21
|
+
- **`--format html`** (#23) — a single self-contained HTML report (inline CSS, no
|
|
22
|
+
external assets) with the score, per-source table, and a card per survivor
|
|
23
|
+
(subject, file:line, operator, stable id, diff).
|
|
24
|
+
- **String, regex, and collection-method operators** (#24, Tier-2, opt-in via
|
|
25
|
+
`--operators`): `string_literal`, `regex`, `collection_method`
|
|
26
|
+
(`map`↔`each`, `all?`↔`any?`, `first`↔`last`, `min`↔`max`, `select`↔`reject`).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **`--dry-run` now honors suppression** (#22) — inline `# mutineer:disable-line`
|
|
30
|
+
and `.mutineer.yml` `ignore:` entries are omitted from the preview and counted
|
|
31
|
+
as "ignored (suppressed)", matching a real run.
|
|
32
|
+
|
|
7
33
|
## [0.8.0] - 2026-06-30
|
|
8
34
|
|
|
9
35
|
### Fixed
|
|
@@ -14,12 +40,14 @@ All notable changes to this project are documented here. The format is based on
|
|
|
14
40
|
are now discovered as singletons, and the redefine strategy re-opens
|
|
15
41
|
`class << self` so the mutation lands on the called method. (Scores for
|
|
16
42
|
singleton-heavy files will rise to their true values.)
|
|
17
|
-
- **
|
|
18
|
-
are `binmode
|
|
19
|
-
error
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
- **Write-heavy Rails tests are capturable again** (#19) — capture/worker pipes
|
|
44
|
+
are `binmode`: a binary Marshal payload over a text-mode pipe could raise an
|
|
45
|
+
encoding error the child then swallowed → empty pipe → false `:uncapturable`.
|
|
46
|
+
That was the root cause of the residual write-heavy failures too. A child that
|
|
47
|
+
dies without writing now also reports how it died (exit status / signal), and
|
|
48
|
+
`--verbose` always surfaces a real reason. Verified on a real Rails app: all 6
|
|
49
|
+
previously-uncapturable interactors (incl. caxlsx + Google-client) now capture
|
|
50
|
+
with real scores, 0 uncapturable.
|
|
23
51
|
|
|
24
52
|
## [0.7.1] - 2026-06-30
|
|
25
53
|
|
|
@@ -156,6 +184,8 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
156
184
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
157
185
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
158
186
|
|
|
187
|
+
[0.9.1]: https://github.com/davidteren/mutineer/releases/tag/v0.9.1
|
|
188
|
+
[0.9.0]: https://github.com/davidteren/mutineer/releases/tag/v0.9.0
|
|
159
189
|
[0.8.0]: https://github.com/davidteren/mutineer/releases/tag/v0.8.0
|
|
160
190
|
[0.7.1]: https://github.com/davidteren/mutineer/releases/tag/v0.7.1
|
|
161
191
|
[0.7.0]: https://github.com/davidteren/mutineer/releases/tag/v0.7.0
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/mutineer)
|
|
4
4
|
[](https://github.com/marketplace/actions/mutineer-ruby)
|
|
5
|
-
[](https://socket.dev/rubygems/package/mutineer)
|
|
6
6
|
|
|
7
7
|
A clean-room mutation-testing tool for Ruby. Mutineer mutates your source one
|
|
8
8
|
change at a time, runs your test suite (Minitest or RSpec) against each mutant, and reports the
|
|
@@ -56,9 +56,10 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
|
56
56
|
| `--jobs N` | Parallel worker count (default: processor count; `1` under `--rails`) |
|
|
57
57
|
| `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
|
|
58
58
|
| `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
|
|
59
|
-
| `--format human\|json` | Report format (default: human) |
|
|
59
|
+
| `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
|
|
60
60
|
| `--output FILE` | Write the report to FILE instead of stdout |
|
|
61
|
-
| `--dry-run` | List candidate mutations without executing |
|
|
61
|
+
| `--dry-run` | List candidate mutations without executing (honors suppression) |
|
|
62
|
+
| `--fail-fast` | Stop at the first surviving mutant |
|
|
62
63
|
| `--list-operators` | List available operators (default vs optional) and exit |
|
|
63
64
|
| `--version`, `--help` | Print version / usage and exit |
|
|
64
65
|
|
|
@@ -75,7 +76,8 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
|
75
76
|
Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
|
|
76
77
|
`comparison`, `boolean_connector`, `boolean_literal`, `statement_removal`.
|
|
77
78
|
Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
|
|
78
|
-
`literal_mutation`, `condition_negation
|
|
79
|
+
`literal_mutation`, `condition_negation`, `string_literal`, `regex`,
|
|
80
|
+
`collection_method`.
|
|
79
81
|
|
|
80
82
|
## Rails apps
|
|
81
83
|
|
data/lib/mutineer/cli.rb
CHANGED
|
@@ -47,9 +47,10 @@ module Mutineer
|
|
|
47
47
|
--boot FILE Require FILE once in the parent to boot the app env, then
|
|
48
48
|
fork per mutant (Rails apps; requires --test)
|
|
49
49
|
--rails Sugar for --boot config/environment --strategy redefine
|
|
50
|
-
--format human|json Report format (default: human)
|
|
50
|
+
--format human|json|html Report format (default: human)
|
|
51
51
|
--output FILE Write the report to FILE instead of stdout
|
|
52
52
|
--dry-run List mutations without executing
|
|
53
|
+
--fail-fast Stop at the first surviving mutant
|
|
53
54
|
--verbose Surface the real error when a fork capture fails (alias: --debug)
|
|
54
55
|
|
|
55
56
|
Options:
|
|
@@ -85,6 +86,7 @@ module Mutineer
|
|
|
85
86
|
end
|
|
86
87
|
o.on("--list-operators") { show_operators = true }
|
|
87
88
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
89
|
+
o.on("--fail-fast") { opts[:fail_fast] = true }
|
|
88
90
|
o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
|
|
89
91
|
o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
|
|
90
92
|
o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
|
|
@@ -209,8 +211,8 @@ module Mutineer
|
|
|
209
211
|
end
|
|
210
212
|
config.jobs = jobs
|
|
211
213
|
|
|
212
|
-
unless %w[human json].include?(config.format)
|
|
213
|
-
warn %(mutineer: unknown format "#{config.format}". Expected: human, json)
|
|
214
|
+
unless %w[human json html].include?(config.format)
|
|
215
|
+
warn %(mutineer: unknown format "#{config.format}". Expected: human, json, html)
|
|
214
216
|
exit 2
|
|
215
217
|
end
|
|
216
218
|
|
|
@@ -377,7 +379,7 @@ module Mutineer
|
|
|
377
379
|
|
|
378
380
|
# #14: nudge toward the opt-in tier-2 operators (human report only — never
|
|
379
381
|
# pollute JSON output).
|
|
380
|
-
if config.format
|
|
382
|
+
if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
|
|
381
383
|
puts hint
|
|
382
384
|
end
|
|
383
385
|
|
|
@@ -411,6 +413,8 @@ module Mutineer
|
|
|
411
413
|
sources = {}
|
|
412
414
|
per_operator = Hash.new(0)
|
|
413
415
|
skipped = 0
|
|
416
|
+
ignored = 0
|
|
417
|
+
ignore_set = config.ignore.to_set
|
|
414
418
|
|
|
415
419
|
# --since narrows the preview to changed lines too, so `--dry-run --since`
|
|
416
420
|
# shows exactly what a real `--since` run would mutate.
|
|
@@ -421,29 +425,36 @@ module Mutineer
|
|
|
421
425
|
|
|
422
426
|
Project.discover(config.sources, only: config.only).each do |subject|
|
|
423
427
|
source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
end
|
|
434
|
-
per_operator[mutation.operator] += 1
|
|
435
|
-
original = source.byteslice(mutation.start_offset...mutation.end_offset)
|
|
436
|
-
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
437
|
-
puts "[#{mutation.operator}] #{subject.qualified_name} " \
|
|
438
|
-
"#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
|
|
428
|
+
# #22: honor suppression so the preview matches what a real run mutates.
|
|
429
|
+
# Mirror execute's per-subject shape (ids need the full mutation list).
|
|
430
|
+
disabled = Runner.suppress_map(source)
|
|
431
|
+
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
432
|
+
ids = MutantId.for_subject(subject, source, mutations)
|
|
433
|
+
mutations.each_with_index do |mutation, i|
|
|
434
|
+
unless mutation.valid?(source)
|
|
435
|
+
skipped += 1
|
|
436
|
+
next
|
|
439
437
|
end
|
|
438
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
439
|
+
next if changed && !changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
|
|
440
|
+
|
|
441
|
+
if Runner.suppressed?(mutation.operator, line, ids[i], disabled, ignore_set)
|
|
442
|
+
ignored += 1
|
|
443
|
+
next
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
per_operator[mutation.operator] += 1
|
|
447
|
+
original = source.byteslice(mutation.start_offset...mutation.end_offset)
|
|
448
|
+
puts "[#{mutation.operator}] #{subject.qualified_name} " \
|
|
449
|
+
"#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
|
|
440
450
|
end
|
|
441
451
|
end
|
|
442
452
|
|
|
443
453
|
total = per_operator.values.sum
|
|
444
454
|
breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
|
|
445
455
|
summary = breakdown.empty? ? "" : "#{breakdown} — "
|
|
446
|
-
puts "#{summary}#{total} mutations (dry run, not executed);
|
|
456
|
+
puts "#{summary}#{total} mutations (dry run, not executed); " \
|
|
457
|
+
"#{skipped} skipped (invalid); #{ignored} ignored (suppressed)"
|
|
447
458
|
exit 0
|
|
448
459
|
end
|
|
449
460
|
end
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -25,13 +25,13 @@ module Mutineer
|
|
|
25
25
|
:cache_dir, :project_root, :load_paths,
|
|
26
26
|
:jobs, :format, :output, :strategy, :require_paths,
|
|
27
27
|
:boot, :rails, :since, :framework, :verbose, :ignore,
|
|
28
|
-
:baseline, :baseline_epsilon,
|
|
28
|
+
:baseline, :baseline_epsilon, :fail_fast,
|
|
29
29
|
keyword_init: true
|
|
30
30
|
) do
|
|
31
31
|
# Config file name.
|
|
32
32
|
CONFIG_FILE = ".mutineer.yml"
|
|
33
33
|
# Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
|
|
34
|
-
KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline].freeze
|
|
34
|
+
KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast].freeze
|
|
35
35
|
|
|
36
36
|
def initialize(**kwargs)
|
|
37
37
|
super
|
|
@@ -50,6 +50,7 @@ module Mutineer
|
|
|
50
50
|
self.verbose = false if verbose.nil?
|
|
51
51
|
self.ignore ||= []
|
|
52
52
|
self.baseline_epsilon ||= 0.0
|
|
53
|
+
self.fail_fast = false if fail_fast.nil?
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
# Walk from `start` toward `home`, returning the first .mutineer.yml path found
|
|
@@ -87,6 +87,27 @@ module Mutineer
|
|
|
87
87
|
failed_test_targets.include?(File.basename(rel, ".rb"))
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# #25: per-METHOD taint. A mutant on a line whose enclosing method got zero
|
|
91
|
+
# successful coverage, in a file a failed sibling test targets, is
|
|
92
|
+
# :uncapturable (the capture that would have covered it errored) — NOT a
|
|
93
|
+
# genuine gap. A method with any covered line means its uncovered lines are a
|
|
94
|
+
# real :no_coverage. A failed capture emits no coverage, so per-line intent is
|
|
95
|
+
# unknowable; method-range + successful coverage is the finest derivable signal.
|
|
96
|
+
# Fully-failed files behave exactly as uncapturable_source? did (every method
|
|
97
|
+
# range has zero coverage), so #8/#9/#19 behavior is unchanged.
|
|
98
|
+
#
|
|
99
|
+
# @param file [String] source file path.
|
|
100
|
+
# @param line_range [Range] 1-based enclosing-method line range.
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def method_uncapturable?(file, line_range)
|
|
103
|
+
return false if @failed_test_files.empty?
|
|
104
|
+
|
|
105
|
+
rel = relativize(absolute(file))
|
|
106
|
+
return false unless failed_test_targets.include?(File.basename(rel, ".rb"))
|
|
107
|
+
|
|
108
|
+
line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
|
|
109
|
+
end
|
|
110
|
+
|
|
90
111
|
private
|
|
91
112
|
|
|
92
113
|
# Source rel-paths that received coverage from any successful capture.
|
|
@@ -8,6 +8,9 @@ require_relative "mutators/statement_removal"
|
|
|
8
8
|
require_relative "mutators/return_nil"
|
|
9
9
|
require_relative "mutators/literal_mutation"
|
|
10
10
|
require_relative "mutators/condition_negation"
|
|
11
|
+
require_relative "mutators/string_literal"
|
|
12
|
+
require_relative "mutators/regex_literal"
|
|
13
|
+
require_relative "mutators/collection_method"
|
|
11
14
|
|
|
12
15
|
module Mutineer
|
|
13
16
|
# Maps operator names to operator classes.
|
|
@@ -28,13 +31,16 @@ module Mutineer
|
|
|
28
31
|
"statement_removal" => Mutators::StatementRemoval,
|
|
29
32
|
"return_nil" => Mutators::ReturnNil,
|
|
30
33
|
"literal_mutation" => Mutators::LiteralMutation,
|
|
31
|
-
"condition_negation" => Mutators::ConditionNegation
|
|
34
|
+
"condition_negation" => Mutators::ConditionNegation,
|
|
35
|
+
"string_literal" => Mutators::StringLiteral,
|
|
36
|
+
"regex" => Mutators::RegexLiteral,
|
|
37
|
+
"collection_method" => Mutators::CollectionMethod
|
|
32
38
|
}.freeze
|
|
33
39
|
|
|
34
40
|
# The default Tier-1 operator set.
|
|
35
41
|
DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
|
|
36
42
|
# Tier-2 operators that remain opt-in.
|
|
37
|
-
TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
|
|
43
|
+
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method].freeze
|
|
38
44
|
|
|
39
45
|
# Short human-readable descriptions for each operator.
|
|
40
46
|
DESCRIPTIONS = {
|
|
@@ -45,7 +51,10 @@ module Mutineer
|
|
|
45
51
|
"statement_removal" => "replace a non-final statement with nil",
|
|
46
52
|
"return_nil" => "replace a return / final expression with nil",
|
|
47
53
|
"literal_mutation" => "integer -> 0, 1, n+1; string -> empty",
|
|
48
|
-
"condition_negation" => "wrap if/unless/ternary condition in !( ... )"
|
|
54
|
+
"condition_negation" => "wrap if/unless/ternary condition in !( ... )",
|
|
55
|
+
"string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
|
|
56
|
+
"regex" => "drop leading ^ / trailing $, swap + <-> *",
|
|
57
|
+
"collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject"
|
|
49
58
|
}.freeze
|
|
50
59
|
|
|
51
60
|
# Resolves operator names to classes.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Collection / enumerable method-name mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Swaps the method-name token of a call to its semantic opposite, one
|
|
10
|
+
# mutation per occurrence, exactly like the arithmetic/comparison operators.
|
|
11
|
+
class CollectionMethod < Base
|
|
12
|
+
# Method-name swaps. All targets are core-Ruby Enumerable/Array methods so
|
|
13
|
+
# the mutant exercises real behaviour rather than always raising.
|
|
14
|
+
#
|
|
15
|
+
# ponytail: include? -> exclude? was specced but skipped — exclude? is not
|
|
16
|
+
# core Ruby, so that mutant would always NoMethodError (a weak mutant).
|
|
17
|
+
REPLACEMENTS = {
|
|
18
|
+
map: "each", each: "map",
|
|
19
|
+
all?: "any?", any?: "all?",
|
|
20
|
+
first: "last", last: "first",
|
|
21
|
+
min: "max", max: "min",
|
|
22
|
+
select: "reject", reject: "select"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Visits call nodes and emits collection-method mutations.
|
|
26
|
+
#
|
|
27
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
28
|
+
# @return [void]
|
|
29
|
+
def visit_call_node(node)
|
|
30
|
+
replacement = REPLACEMENTS[node.name]
|
|
31
|
+
loc = node.message_loc
|
|
32
|
+
if replacement && loc
|
|
33
|
+
@mutations << Mutation.new(
|
|
34
|
+
start_offset: loc.start_offset,
|
|
35
|
+
end_offset: loc.end_offset,
|
|
36
|
+
replacement: replacement,
|
|
37
|
+
operator: :collection_method
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Regular-expression literal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Conservative, clearly-safe textual swaps inside a plain regex literal:
|
|
10
|
+
# drop a leading +^+ anchor, drop a trailing +$+ anchor, and swap the +
|
|
11
|
+
# and * quantifiers. Interpolated regexes use a different node type and are
|
|
12
|
+
# never visited. Escaped tokens (preceded by a backslash) are left alone.
|
|
13
|
+
class RegexLiteral < Base
|
|
14
|
+
# Visits regular-expression literals.
|
|
15
|
+
#
|
|
16
|
+
# @param node [Prism::RegularExpressionNode] node to inspect.
|
|
17
|
+
# @return [void]
|
|
18
|
+
def visit_regular_expression_node(node)
|
|
19
|
+
loc = node.content_loc
|
|
20
|
+
content = @source.byteslice(loc.start_offset...loc.end_offset)
|
|
21
|
+
scan(content, loc.start_offset)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Scans the pattern bytes and emits anchor/quantifier mutations.
|
|
28
|
+
#
|
|
29
|
+
# @param content [String] regex pattern between the slashes.
|
|
30
|
+
# @param base [Integer] byte offset of the pattern start in source.
|
|
31
|
+
# @return [void]
|
|
32
|
+
def scan(content, base)
|
|
33
|
+
emit(base, base + 1, "") if content.start_with?("^")
|
|
34
|
+
emit(base + content.bytesize - 1, base + content.bytesize, "") if trailing_anchor?(content)
|
|
35
|
+
|
|
36
|
+
escaped = false
|
|
37
|
+
offset = base
|
|
38
|
+
content.each_char do |ch|
|
|
39
|
+
unless escaped
|
|
40
|
+
emit(offset, offset + 1, "*") if ch == "+"
|
|
41
|
+
emit(offset, offset + 1, "+") if ch == "*"
|
|
42
|
+
end
|
|
43
|
+
escaped = ch == "\\" && !escaped
|
|
44
|
+
offset += ch.bytesize
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether the pattern ends with an unescaped +$+ anchor.
|
|
49
|
+
#
|
|
50
|
+
# @param content [String] regex pattern.
|
|
51
|
+
# @return [Boolean] true when a trailing anchor is droppable.
|
|
52
|
+
def trailing_anchor?(content)
|
|
53
|
+
content.end_with?("$") && !content.end_with?("\\$")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Emits a regex mutation.
|
|
57
|
+
#
|
|
58
|
+
# @param start_offset [Integer] byte start.
|
|
59
|
+
# @param end_offset [Integer] byte end.
|
|
60
|
+
# @param replacement [String] replacement text.
|
|
61
|
+
# @return [void]
|
|
62
|
+
def emit(start_offset, end_offset, replacement)
|
|
63
|
+
@mutations << Mutation.new(
|
|
64
|
+
start_offset: start_offset,
|
|
65
|
+
end_offset: end_offset,
|
|
66
|
+
replacement: replacement,
|
|
67
|
+
operator: :regex
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# String literal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Empties a non-empty string literal and fills an empty one. Only plain
|
|
10
|
+
# quoted strings are touched; interpolated strings, heredocs and %-literals
|
|
11
|
+
# are skipped for safety (they re-parse unpredictably).
|
|
12
|
+
class StringLiteral < Base
|
|
13
|
+
# Visits string literals.
|
|
14
|
+
#
|
|
15
|
+
# @param node [Prism::StringNode] node to inspect.
|
|
16
|
+
# @return [void]
|
|
17
|
+
def visit_string_node(node)
|
|
18
|
+
# ponytail: only plain "..." / '...' quotes. opening is nil for
|
|
19
|
+
# interpolation parts and %w[] elements; heredocs/%-literals use a
|
|
20
|
+
# different opening token. Skipping them keeps mutants re-parseable.
|
|
21
|
+
if %w[" '].include?(node.opening)
|
|
22
|
+
loc = node.content_loc
|
|
23
|
+
@mutations << Mutation.new(
|
|
24
|
+
start_offset: loc.start_offset,
|
|
25
|
+
end_offset: loc.end_offset,
|
|
26
|
+
replacement: node.unescaped.empty? ? "mutineer" : "",
|
|
27
|
+
operator: :string_literal
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "stringio"
|
|
5
|
+
require "cgi"
|
|
5
6
|
require_relative "mutation"
|
|
6
7
|
|
|
7
8
|
module Mutineer
|
|
@@ -25,6 +26,8 @@ module Mutineer
|
|
|
25
26
|
rendered =
|
|
26
27
|
if format == "json"
|
|
27
28
|
json_report(baseline)
|
|
29
|
+
elsif format == "html"
|
|
30
|
+
html_report
|
|
28
31
|
else
|
|
29
32
|
sio = StringIO.new
|
|
30
33
|
human_report(sio, err, threshold)
|
|
@@ -128,6 +131,117 @@ module Mutineer
|
|
|
128
131
|
"#{JSON.generate(doc)}\n"
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
# #23: one self-contained HTML file (inline CSS, no external assets) — the
|
|
135
|
+
# overall score + summary counts, a per-source table, and every surviving
|
|
136
|
+
# mutant with its stable id and diff. All source/diff/identifier text is
|
|
137
|
+
# HTML-escaped (CGI.escapeHTML) so a `<`/`>` in source can never break the
|
|
138
|
+
# markup. Reuses survivor_json/per_source_json so one run yields one set of
|
|
139
|
+
# facts regardless of --format.
|
|
140
|
+
def html_report
|
|
141
|
+
score = @agg.mutation_score
|
|
142
|
+
survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
143
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator]] }
|
|
144
|
+
per_source = @agg.by_source.map { |file, agg| per_source_json(file, agg) }
|
|
145
|
+
.sort_by { |h| h[:file] }
|
|
146
|
+
|
|
147
|
+
<<~HTML
|
|
148
|
+
<!DOCTYPE html>
|
|
149
|
+
<html lang="en">
|
|
150
|
+
<head>
|
|
151
|
+
<meta charset="utf-8">
|
|
152
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
153
|
+
<title>Mutineer Mutation Report</title>
|
|
154
|
+
<style>
|
|
155
|
+
body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
156
|
+
margin: 2rem; color: #1b1b1b; background: #fafafa; }
|
|
157
|
+
h1 { margin: 0 0 .25rem; }
|
|
158
|
+
.score { font-size: 2.5rem; font-weight: 700; }
|
|
159
|
+
.counts { color: #444; margin: .5rem 0 1.5rem; }
|
|
160
|
+
.counts span { display: inline-block; margin-right: 1rem; white-space: nowrap; }
|
|
161
|
+
table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; background: #fff; }
|
|
162
|
+
th, td { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
|
|
163
|
+
th { background: #f0f0f0; }
|
|
164
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
165
|
+
.survivor { background: #fff; border: 1px solid #ddd; border-radius: 4px;
|
|
166
|
+
padding: .75rem 1rem; margin-bottom: 1rem; }
|
|
167
|
+
.survivor h3 { margin: 0 0 .25rem; font-size: 1rem; }
|
|
168
|
+
.meta { color: #555; font-size: .85rem; margin-bottom: .5rem; }
|
|
169
|
+
.id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
170
|
+
pre.diff { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
171
|
+
background: #f6f8fa; padding: .5rem .75rem; overflow-x: auto;
|
|
172
|
+
margin: 0; border-radius: 4px; }
|
|
173
|
+
.diff .add { color: #116329; }
|
|
174
|
+
.diff .del { color: #82071e; }
|
|
175
|
+
</style>
|
|
176
|
+
</head>
|
|
177
|
+
<body>
|
|
178
|
+
<h1>Mutineer — Mutation Report</h1>
|
|
179
|
+
<div class="score">Score: #{score.nil? ? 'N/A' : "#{score}%"}</div>
|
|
180
|
+
#{summary_html}
|
|
181
|
+
#{per_source_html(per_source)}
|
|
182
|
+
#{survivors_html(survivors)}
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
185
|
+
HTML
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The summary counts block for the HTML header.
|
|
189
|
+
def summary_html
|
|
190
|
+
counts = {
|
|
191
|
+
"total" => @agg.total, "killed" => @agg.killed_count,
|
|
192
|
+
"survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
|
|
193
|
+
"uncapturable" => @agg.uncapturable_count, "ignored" => @agg.ignored_count,
|
|
194
|
+
"skipped" => @agg.skipped_invalid_count,
|
|
195
|
+
"errored" => @agg.errored_count + @agg.timeout_count
|
|
196
|
+
}
|
|
197
|
+
spans = counts.map { |k, v| "<span><strong>#{v}</strong> #{esc(k)}</span>" }.join("\n ")
|
|
198
|
+
"<div class=\"counts\">\n #{spans}\n</div>"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The per-source breakdown table.
|
|
202
|
+
def per_source_html(per_source)
|
|
203
|
+
return "" if per_source.empty?
|
|
204
|
+
|
|
205
|
+
rows = per_source.map do |h|
|
|
206
|
+
score = h[:score].nil? ? "N/A" : "#{h[:score]}%"
|
|
207
|
+
"<tr><td>#{esc(h[:file])}</td><td class=\"num\">#{score}</td>" \
|
|
208
|
+
"<td class=\"num\">#{h[:killed]}</td><td class=\"num\">#{h[:survived]}</td>" \
|
|
209
|
+
"<td class=\"num\">#{h[:no_coverage]}</td></tr>"
|
|
210
|
+
end.join("\n ")
|
|
211
|
+
<<~HTML.chomp
|
|
212
|
+
<h2>Per-source</h2>
|
|
213
|
+
<table>
|
|
214
|
+
<tr><th>File</th><th>Score</th><th>Killed</th><th>Survived</th><th>No coverage</th></tr>
|
|
215
|
+
#{rows}
|
|
216
|
+
</table>
|
|
217
|
+
HTML
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The surviving-mutants list, each with subject, location, operator, stable
|
|
221
|
+
# id, and a colorized diff. All text is HTML-escaped.
|
|
222
|
+
def survivors_html(survivors)
|
|
223
|
+
return "<h2>Surviving Mutants</h2>\n<p>None — every covered mutant was killed.</p>" if survivors.empty?
|
|
224
|
+
|
|
225
|
+
cards = survivors.map do |s|
|
|
226
|
+
diff_lines = s[:diff].each_line.map do |line|
|
|
227
|
+
cls = line.start_with?("+") ? "add" : (line.start_with?("-") ? "del" : nil)
|
|
228
|
+
text = esc(line.chomp)
|
|
229
|
+
cls ? "<span class=\"#{cls}\">#{text}</span>" : text
|
|
230
|
+
end.join("\n")
|
|
231
|
+
<<~CARD.chomp
|
|
232
|
+
<div class="survivor">
|
|
233
|
+
<h3>#{esc(s[:subject])}</h3>
|
|
234
|
+
<div class="meta">#{esc(s[:file])}:#{s[:line]} · #{esc(s[:operator])} · <span class="id">#{esc(s[:id])}</span></div>
|
|
235
|
+
<pre class="diff">#{diff_lines}</pre>
|
|
236
|
+
</div>
|
|
237
|
+
CARD
|
|
238
|
+
end.join("\n")
|
|
239
|
+
"<h2>Surviving Mutants</h2>\n#{cards}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# HTML-escapes any text destined for the document (stdlib CGI).
|
|
243
|
+
def esc(text) = CGI.escapeHTML(text.to_s)
|
|
244
|
+
|
|
131
245
|
# #13: the same delta facts the human report prints, for dashboards. new_survivors
|
|
132
246
|
# reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
|
|
133
247
|
# byte-stably so output doesn't depend on --jobs finish order.
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -127,13 +127,17 @@ module Mutineer
|
|
|
127
127
|
results =
|
|
128
128
|
begin
|
|
129
129
|
framework = config.framework
|
|
130
|
-
|
|
130
|
+
# #21: --fail-fast stops scheduling new mutants after the first survivor;
|
|
131
|
+
# in-flight workers drain, unscheduled jobs stay nil (dropped below).
|
|
132
|
+
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
133
|
+
bare = WorkerPool.new(config.jobs).run(jobs, stop_when: stop_when) do |subject, mutation|
|
|
131
134
|
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
132
135
|
subject: subject, strategy: strategy, rails: config.rails, framework: framework)
|
|
133
136
|
end
|
|
134
137
|
# The bare Results carry only status (Subjects hold live AST nodes that
|
|
135
138
|
# do not marshal); reattach subject+mutation+id in the parent, in order.
|
|
136
|
-
|
|
139
|
+
# filter_map drops nils for jobs --fail-fast left unscheduled.
|
|
140
|
+
bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
|
|
137
141
|
ensure
|
|
138
142
|
sweep_orphans(source_dirs)
|
|
139
143
|
end
|
|
@@ -255,10 +259,18 @@ module Mutineer
|
|
|
255
259
|
# covering test files run in the child.
|
|
256
260
|
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
257
261
|
chosen = coverage_map.tests_for(source_file, line)
|
|
258
|
-
# #9: distinguish a genuine coverage gap from a line whose would-be test
|
|
262
|
+
# #9/#25: distinguish a genuine coverage gap from a line whose would-be test
|
|
259
263
|
# errored during capture (coverage lost) — the latter is :uncapturable.
|
|
264
|
+
# #25: taint per-METHOD (the mutant's enclosing def range), not whole-file,
|
|
265
|
+
# so a covered method's uncovered line stays :no_coverage while a method
|
|
266
|
+
# reachable only by a failed capture is :uncapturable.
|
|
260
267
|
if chosen.empty?
|
|
261
|
-
|
|
268
|
+
# Use the method BODY range, not the whole def: the `def`/`end` lines are
|
|
269
|
+
# "covered" at class-load even when the body never runs, which would mask
|
|
270
|
+
# an uncovered method. body_loc is the body statements' span.
|
|
271
|
+
loc = subject&.body_loc
|
|
272
|
+
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
273
|
+
return coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage
|
|
262
274
|
end
|
|
263
275
|
|
|
264
276
|
abs_tests = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer/worker_pool.rb
CHANGED
|
@@ -25,16 +25,25 @@ module Mutineer
|
|
|
25
25
|
# Runs the work items through the pool.
|
|
26
26
|
#
|
|
27
27
|
# @param items [Array<Array>] work items.
|
|
28
|
+
# @param stop_when [Proc, nil] called with each collected Result; when it
|
|
29
|
+
# returns truthy, no further items are scheduled and the run drains and
|
|
30
|
+
# returns early (#21 --fail-fast). Unscheduled slots stay nil.
|
|
28
31
|
# @yieldparam item [Array] one work item.
|
|
29
|
-
# @return [Array<Mutineer::Result>] results in input order
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
# @return [Array<Mutineer::Result>] results in input order (nil for any item
|
|
33
|
+
# left unscheduled by an early stop).
|
|
34
|
+
def run(items, stop_when: nil)
|
|
35
|
+
results = Array.new(items.size)
|
|
36
|
+
queue = (0...items.size).to_a
|
|
37
|
+
running = {} # pid => [index, read_io, buffer]
|
|
38
|
+
stopping = false
|
|
34
39
|
|
|
35
40
|
until queue.empty? && running.empty?
|
|
36
|
-
fill(items, queue, running) { |*args| yield(*args) }
|
|
37
|
-
reap(results, running)
|
|
41
|
+
fill(items, queue, running) { |*args| yield(*args) } unless stopping
|
|
42
|
+
result = reap(results, running)
|
|
43
|
+
if !stopping && stop_when && result && stop_when.call(result)
|
|
44
|
+
stopping = true
|
|
45
|
+
queue.clear # schedule no more; let in-flight workers drain
|
|
46
|
+
end
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
results
|
|
@@ -107,8 +116,8 @@ module Mutineer
|
|
|
107
116
|
rd.close
|
|
108
117
|
Process.waitpid(pid) # reap the now-finished child (no zombie)
|
|
109
118
|
running.delete(pid)
|
|
110
|
-
|
|
111
|
-
return
|
|
119
|
+
# Return the collected Result so the caller's stop_when (#21) can see it.
|
|
120
|
+
return results[idx] = decode(buf)
|
|
112
121
|
end
|
|
113
122
|
buf << chunk
|
|
114
123
|
end
|
data/lib/mutineer.rb
CHANGED
|
@@ -22,6 +22,9 @@ require_relative "mutineer/mutators/statement_removal"
|
|
|
22
22
|
require_relative "mutineer/mutators/return_nil"
|
|
23
23
|
require_relative "mutineer/mutators/literal_mutation"
|
|
24
24
|
require_relative "mutineer/mutators/condition_negation"
|
|
25
|
+
require_relative "mutineer/mutators/string_literal"
|
|
26
|
+
require_relative "mutineer/mutators/regex_literal"
|
|
27
|
+
require_relative "mutineer/mutators/collection_method"
|
|
25
28
|
require_relative "mutineer/mutator_registry"
|
|
26
29
|
require_relative "mutineer/worker_pool"
|
|
27
30
|
require_relative "mutineer/runner"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutineer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -80,11 +80,14 @@ files:
|
|
|
80
80
|
- lib/mutineer/mutators/base.rb
|
|
81
81
|
- lib/mutineer/mutators/boolean_connector.rb
|
|
82
82
|
- lib/mutineer/mutators/boolean_literal.rb
|
|
83
|
+
- lib/mutineer/mutators/collection_method.rb
|
|
83
84
|
- lib/mutineer/mutators/comparison.rb
|
|
84
85
|
- lib/mutineer/mutators/condition_negation.rb
|
|
85
86
|
- lib/mutineer/mutators/literal_mutation.rb
|
|
87
|
+
- lib/mutineer/mutators/regex_literal.rb
|
|
86
88
|
- lib/mutineer/mutators/return_nil.rb
|
|
87
89
|
- lib/mutineer/mutators/statement_removal.rb
|
|
90
|
+
- lib/mutineer/mutators/string_literal.rb
|
|
88
91
|
- lib/mutineer/pairing.rb
|
|
89
92
|
- lib/mutineer/parser.rb
|
|
90
93
|
- lib/mutineer/project.rb
|