mutineer 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfadc70b171d8778c8afa95021c2989ad33ab2880200a22886fec274e358efcc
4
- data.tar.gz: f6f6c91f6e5fffc0a2d05d93b0981df165d6d5b2df1e1434949c7dea1c0f70a6
3
+ metadata.gz: a8687c96cedac16e86197f3639803487657512509c45a1969449eb95b3c4b65c
4
+ data.tar.gz: 59588c405f16476b984fefbda003299f8373687cacd40c4569ea72edb866d0a9
5
5
  SHA512:
6
- metadata.gz: 01de0a56c54a7b85fd98d92831681a258a4420b3e5b3d34ce932811259a776764c888aae13a8975632c66851798f9b4a53f343864518fd8fe608e7dddff20e07
7
- data.tar.gz: 63c802e363a3f87a8ecd43a5bf726d2ce23af2aadf890b87b7ad6616076e9f2461876c0af5faa70c83caf93027fd9e8754bf6866c5ab25e7d2a51bd51f9f3d4c
6
+ metadata.gz: edf0bff64186f9719f4de85a0672e7b6614d161aa02b87b63600ebdb2e82e744ef706fd0db29a990d159be3ed422a35b9e3fd7d505595ca585e483fe9ababce1
7
+ data.tar.gz: 1e41589e16979a227b9a479504574d939ff5f91077535723e1b3cfc87e53fa20475e1ba1c09dce910b9644bc002f631ac5001d60737a98a9940ab06ea7dbb023
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ 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.0] - 2026-06-30
8
+
9
+ ### Added
10
+ - **`--fail-fast`** (#21) — stop at the first surviving mutant; in-flight workers
11
+ drain, the rest are skipped. Fast red signal for PR gates.
12
+ - **`--format html`** (#23) — a single self-contained HTML report (inline CSS, no
13
+ external assets) with the score, per-source table, and a card per survivor
14
+ (subject, file:line, operator, stable id, diff).
15
+ - **String, regex, and collection-method operators** (#24, Tier-2, opt-in via
16
+ `--operators`): `string_literal`, `regex`, `collection_method`
17
+ (`map`↔`each`, `all?`↔`any?`, `first`↔`last`, `min`↔`max`, `select`↔`reject`).
18
+
19
+ ### Changed
20
+ - **`--dry-run` now honors suppression** (#22) — inline `# mutineer:disable-line`
21
+ and `.mutineer.yml` `ignore:` entries are omitted from the preview and counted
22
+ as "ignored (suppressed)", matching a real run.
23
+
7
24
  ## [0.8.0] - 2026-06-30
8
25
 
9
26
  ### Fixed
@@ -14,12 +31,14 @@ All notable changes to this project are documented here. The format is based on
14
31
  are now discovered as singletons, and the redefine strategy re-opens
15
32
  `class << self` so the mutation lands on the called method. (Scores for
16
33
  singleton-heavy files will rise to their true values.)
17
- - **Fork-capture failures are diagnosable** (#19, part 1) — capture/worker pipes
18
- are `binmode` (a binary Marshal payload could otherwise be lost to an encoding
19
- error and swallowed), and a child that dies without writing now reports how it
20
- died (exit status / signal) instead of a silent "produced no result". `--verbose`
21
- always surfaces a real reason now. (The residual write-heavy capture failure
22
- on some real Rails suites remains under investigation now diagnosable.)
34
+ - **Write-heavy Rails tests are capturable again** (#19) — capture/worker pipes
35
+ are `binmode`: a binary Marshal payload over a text-mode pipe could raise an
36
+ encoding error the child then swallowed empty pipe false `:uncapturable`.
37
+ That was the root cause of the residual write-heavy failures too. A child that
38
+ dies without writing now also reports how it died (exit status / signal), and
39
+ `--verbose` always surfaces a real reason. Verified on a real Rails app: all 6
40
+ previously-uncapturable interactors (incl. caxlsx + Google-client) now capture
41
+ with real scores, 0 uncapturable.
23
42
 
24
43
  ## [0.7.1] - 2026-06-30
25
44
 
@@ -156,6 +175,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
156
175
  - `.mutineer.yml` configuration (CLI > config > default precedence).
157
176
  - Byte-correct source handling for multibyte (UTF-8) sources.
158
177
 
178
+ [0.9.0]: https://github.com/davidteren/mutineer/releases/tag/v0.9.0
159
179
  [0.8.0]: https://github.com/davidteren/mutineer/releases/tag/v0.8.0
160
180
  [0.7.1]: https://github.com/davidteren/mutineer/releases/tag/v0.7.1
161
181
  [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
  [![Gem Version](https://img.shields.io/gem/v/mutineer?logo=rubygems&color=e23b3b)](https://rubygems.org/gems/mutineer)
4
4
  [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Mutineer%20Ruby-2da44e?logo=githubactions&logoColor=white)](https://github.com/marketplace/actions/mutineer-ruby)
5
- [![Socket Badge](https://badge.socket.dev/rubygems/package/mutineer/0.7.1?platform=ruby)](https://socket.dev/rubygems/package/mutineer/overview/0.7.1?platform=ruby)
5
+ [![Socket](https://img.shields.io/badge/Socket-security%20report-0a66c2?logo=socket&logoColor=white)](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 != "json" && (hint = tier2_hint(config.operators))
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
- operator_classes.each do |klass|
425
- klass.new.mutations_for(subject, source).each do |mutation|
426
- unless mutation.valid?(source)
427
- skipped += 1
428
- next
429
- end
430
- if changed
431
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
432
- next unless changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
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); #{skipped} skipped (invalid)"
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
@@ -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
@@ -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
@@ -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]} &middot; #{esc(s[:operator])} &middot; <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.
@@ -127,13 +127,17 @@ module Mutineer
127
127
  results =
128
128
  begin
129
129
  framework = config.framework
130
- bare = WorkerPool.new(config.jobs).run(jobs) do |subject, mutation|
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
- bare.each_with_index.map { |r, i| r.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
@@ -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
- def run(items)
31
- results = Array.new(items.size)
32
- queue = (0...items.size).to_a
33
- running = {} # pid => [index, read_io]
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
- results[idx] = decode(buf)
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.8.0
4
+ version: 0.9.0
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