mutineer 0.11.4 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85591c1b61bcdc3c8a1d39b5b657f667bf7de9039d61e05ab3e5691692f555f0
4
- data.tar.gz: 76acdc054e82d9ea10dee4a63bcbb5f0f86db37dd585323f734991f413f21e61
3
+ metadata.gz: a96e7bad1e1cf8d106957e770418e6a1854c677943114b9bc45421c4e06da7f2
4
+ data.tar.gz: c404d87071f3d270566e368ac70153f1e87c940eb170b220b972fb1b28b29889
5
5
  SHA512:
6
- metadata.gz: b5855d2e0a9d4e226fe0ad2034cc4d08f4b5ac5e2d34df9e92f97b9d344ea5a50c4df01bbdcb0973dc66e8ccde717baab8842935fa07a4cad1238f77b9bebf42
7
- data.tar.gz: 3a5c57795de651128a3d3597b2f1776fd32ff76aeaea4edf14037a1ec32e13e72e54c38a56ef8c209f3ee1d6afc0ffbb5fd47153967803c841e64d989d80a047
6
+ metadata.gz: 15417ab5322c43c537953eaa438c18a9a89794f199a9c4666f031daf60f55f6c3ffabcb2e69e15b7ffd030e48b6f2d5d3ab41ee01ccfe9d52dd6c9ee22b4971c
7
+ data.tar.gz: 1e68e920b3aa610ea1715c16e48e89060e8eba2b2511aafb3c7db0c44e21f8fac077529173befd9acf3afbe2962829cf4534dce371a2f6262628dd507f37469b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,95 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.1] - 2026-09-18
10
+
11
+ ### Added
12
+ - **RubyGems `documentation_uri`**: the published gem now points at the docs
13
+ site (`https://davidteren.github.io/mutineer/`) so gem-page discovery
14
+ reaches the Pages docs (#90).
15
+
16
+ ### Fixed
17
+ - **A red unmutated suite can no longer pass a mutation gate** — coverage
18
+ capture now keeps the original Minitest/RSpec result, and a failing clean run
19
+ aborts with the existing smoke-check error (exit 1) instead of scoring those
20
+ assertion failures as killed mutants. A warm coverage cache re-checks the
21
+ current suite and cannot bypass this (#96).
22
+ - **Concurrent external runs no longer restore each other's source files** —
23
+ swap and orphan recovery share one exclusive OS lock per source, acquired
24
+ before reading or healing. A live owner's mutant and backup stay intact; a
25
+ dead owner's backup still restores the original bytes (#99).
26
+ - **Coverage cache now invalidates when a required test helper changes** —
27
+ a successful map records fingerprints of project-local loaded Ruby files,
28
+ old cache entries without that data rebuild, and a helper-only edit no
29
+ longer hides a new survivor behind a stale 100% score (#97).
30
+ - **Release version calculation ignores floating major tags** — `release-pr.yml`
31
+ selects the newest complete `vMAJOR.MINOR.PATCH` ancestor and validates the
32
+ next version before writing files, so a later `v1` tag can no longer produce
33
+ `v1..1` (#95).
34
+
35
+ ## [1.0.0] - 2026-09-08
36
+
37
+ The GitHub Action's PR default changes in this release, which is why it is a
38
+ new major: workflows pinned to `davidteren/mutineer@v0` keep the old full-scan
39
+ behavior; upgrading to `@v1` opts into diff-scoped PR runs (details under
40
+ Changed).
41
+
42
+ ### Added
43
+ - **The Action reports where CI readers look**: with the default JSON format it
44
+ writes a score/pass-fail table (plus the baseline delta, when `baseline` is
45
+ set) to the job step summary, and emits one `file=…,line=…` annotation per
46
+ surviving mutant (up to 50; the summary lists the first 20, the full set
47
+ stays in the JSON report) so results land on the PR diff instead of only in a
48
+ collapsed log group — `error` level when the gate failed, `warning` when it
49
+ passed. When `output` is unset the JSON report is routed to a temp file and
50
+ still printed to the log, and the `report` output now exposes the report
51
+ path in both cases so a later step can consume the JSON without scraping
52
+ the log (#86).
53
+ - **Progress during the run**: every backend prints `[mutineer] N/M mutants
54
+ (P%)` to stderr at each 10% step, so a long run is never silent between
55
+ config resolution and the report. Stdout stays byte-exact for `--format json`
56
+ and `--output`. `WorkerPool#run` gains an optional `on_result:` callback for
57
+ this (called in the parent per reaped result) (#86).
58
+
59
+ ### Changed
60
+ - **PR runs scope themselves in the GitHub Action**: on `pull_request` events
61
+ the `since` input now defaults to the PR base, so the action grades just the
62
+ diff out of the box. **Migration note (the reason for the major bump)**: a
63
+ PR gate that previously full-scanned now scores only the PR's changed lines,
64
+ so `threshold` applies to fewer mutants. Stay on `@v0` to keep the old
65
+ default, or pass `since: none` on `@v1` for full scans. Workflows that
66
+ already pass a non-empty `since` are unchanged (an explicit empty string is
67
+ indistinguishable from unset and picks up the new default). A PR with no
68
+ changed source lines (docs- or test-only) scores zero mutants and passes the
69
+ scoped gate vacuously; keep a full-scan baseline refresh on main as the
70
+ backstop. With `use-bundler: true` the caller's Gemfile picks the gem, and
71
+ the new default needs mutineer >= 1.0.0 (the action enforces the floor with
72
+ a clear error). The action scopes to the PR's exact base commit from the
73
+ event payload (immune to the
74
+ base branch advancing mid-job), falling back to a fresh fetch of the base
75
+ branch tip; when neither can be resolved it warns and runs without an
76
+ action-provided `--since` (a `.mutineer.yml` `since:` key, if any, still
77
+ applies). The default deliberately does NOT fire on `pull_request_target`:
78
+ checkout there defaults to the base branch, so auto-scoping would diff the
79
+ base against itself and green the gate on an empty run (#86).
80
+ - **`--baseline` on a diff-scoped run gates on new survivors only**: a
81
+ `--since` run's score covers only the changed-line mutants, a different
82
+ denominator from a full-run baseline, so comparing the two scores
83
+ manufactured false regressions (a 3/4-mutant PR at 75% "dropped" from a
84
+ 92% whole-repo baseline with zero new survivors). With `--since`, the
85
+ score-drop half of the baseline gate is skipped; new-survivor detection by
86
+ stable id (and the reported before/after scores) are unchanged. The JSON
87
+ report records the scope in a new additive `summary.scoped` key
88
+ (`schema_version` 1.3), and the `baseline` block records `score_comparable`
89
+ so a consumer knows when not to render the two scores as a comparison
90
+ (`fixed_survivors` is likewise empty under a scoped side: an out-of-scope
91
+ survivor was never re-tested, so absence does not mean fixed). The reverse
92
+ direction is a hard guard: a scoped report is refused as a baseline (exit 2
93
+ with a regenerate hint), because survivors outside its diff would all read
94
+ as new regressions. A new `--no-since` flag disables diff scoping explicitly: a
95
+ typed no beats a `.mutineer.yml` `since:` key, and the action's
96
+ `since: none` passes it through (#86).
97
+
9
98
  ## [0.11.4] - 2026-07-29
10
99
 
11
100
  ### Fixed
@@ -318,6 +407,8 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
318
407
  - `.mutineer.yml` configuration (CLI > config > default precedence).
319
408
  - Byte-correct source handling for multibyte (UTF-8) sources.
320
409
 
410
+ [1.0.1]: https://github.com/davidteren/mutineer/releases/tag/v1.0.1
411
+ [1.0.0]: https://github.com/davidteren/mutineer/releases/tag/v1.0.0
321
412
  [0.11.4]: https://github.com/davidteren/mutineer/releases/tag/v0.11.4
322
413
  [0.11.3]: https://github.com/davidteren/mutineer/releases/tag/v0.11.3
323
414
  [0.11.2]: https://github.com/davidteren/mutineer/releases/tag/v0.11.2
data/README.md CHANGED
@@ -51,7 +51,8 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
51
51
  | `--only NAME` | Restrict to one fully-qualified subject, e.g. `Calculator#add` |
52
52
  | `--framework NAME` | `minitest` (default) or `rspec`; auto-detected as rspec when most `--test` files end in `_spec.rb` |
53
53
  | `--since REF` | Only mutate lines changed since git `REF` (e.g. `origin/main`) — ideal for PR CI |
54
- | `--baseline FILE` | Compare against a prior `--format json` run; exit 1 on new survivors / score drop (see [CI](#ci-gating)) |
54
+ | `--no-since` | Disable diff scoping; a typed no beats a `.mutineer.yml` `since:` key |
55
+ | `--baseline FILE` | Compare against a prior `--format json` run; exit 1 on new survivors / score drop (score drop is skipped under `--since`, whose score covers a different denominator; see [CI](#ci-gating)) |
55
56
  | `--baseline-epsilon FLOAT` | Score-drop tolerance for `--baseline` (default: 0) |
56
57
  | `--jobs N` | Parallel worker count (default: processor count; `1` under `--rails`) |
57
58
  | `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
@@ -233,17 +234,32 @@ This repo ships a composite action (`action.yml`) that wraps the CLI for CI:
233
234
 
234
235
  ```yaml
235
236
  - uses: actions/checkout@v4
236
- with: { fetch-depth: 0 } # --since needs full history
237
237
  - uses: ruby/setup-ruby@v1
238
238
  with: { ruby-version: "3.4", bundler-cache: true }
239
- - uses: davidteren/mutineer@v0
239
+ - uses: davidteren/mutineer@v1
240
240
  with:
241
241
  sources: app/
242
- since: origin/${{ github.base_ref }}
243
242
  baseline: .mutineer/baseline.json
244
243
  threshold: "90"
245
244
  ```
246
245
 
246
+ **Default change:** on `pull_request` events (not `pull_request_target`) the
247
+ action scopes the run to the PR's changed lines, diffing against the PR's exact
248
+ base commit (fetched by the action itself when the checkout is shallow; falls
249
+ back to the base branch tip). Pass `since: none` for a full scan, or an
250
+ explicit `since:` ref (which needs `fetch-depth: 0` on checkout).
251
+
252
+ With the default JSON format the action also:
253
+
254
+ - writes a score summary to the job's step summary;
255
+ - annotates surviving mutants on the PR diff, up to 50 (`error` level when the
256
+ gate failed, `warning` when it passed);
257
+ - exposes the report path via the `report` output for later steps (with
258
+ `format: human`/`html` this needs the `output` input).
259
+
260
+ The CLI prints a progress line to the log at every 10% of the run, whatever
261
+ the format.
262
+
247
263
  ## For AI agents & pipelines
248
264
 
249
265
  Mutineer is built for programmatic use — versioned JSON, stable mutant ids,
@@ -15,12 +15,19 @@ module Mutineer
15
15
  # new_survivors - current Result objects whose stable id is absent from
16
16
  # the baseline (the regressions to name).
17
17
  # fixed_survivors - baseline survivor hashes absent from the current run
18
- # (informational, never gates).
18
+ # (informational, never gates). Empty when either side
19
+ # is diff-scoped: an out-of-scope baseline survivor was
20
+ # never re-tested, so absence does not mean fixed.
19
21
  # score_drop - current score < baseline score - epsilon. nil on
20
- # either side skips the check (see #diff).
22
+ # either side skips the check, and a diff-scoped run
23
+ # (`scoped: true`) never sets it (see #diff).
24
+ # score_comparable - the two scores share a denominator (neither side was
25
+ # diff-scoped and both are non-nil), so a consumer may
26
+ # render them side by side. False means the score-drop
27
+ # check was skipped, not that it passed.
21
28
  # regressed - any new survivors OR a score drop.
22
29
  Delta = Data.define(:new_survivors, :fixed_survivors,
23
- :score_before, :score_after, :score_drop, :regressed)
30
+ :score_before, :score_after, :score_drop, :score_comparable, :regressed)
24
31
 
25
32
  # Load a prior --format json run. Raises ConfigError (NOT exit: a data class
26
33
  # must never kill the host) on a missing/unreadable file, unparseable JSON,
@@ -35,6 +42,15 @@ module Mutineer
35
42
  unless doc.is_a?(Hash) && doc["schema_version"] && doc["survivors"].is_a?(Array)
36
43
  raise ConfigError, "not a Mutineer JSON report: #{path}"
37
44
  end
45
+ # A diff-scoped report covers only that diff's mutants: used as a
46
+ # baseline, every survivor outside the original diff would read as a NEW
47
+ # regression, and its score shares no denominator with any other run.
48
+ # Refuse loudly (exit 2 via the CLI) rather than gate unreliably.
49
+ if doc.dig("summary", "scoped") == true
50
+ raise ConfigError, "#{path} was written by a --since run and covers only that diff's " \
51
+ "mutants; regenerate the baseline from a full run " \
52
+ "(use --no-since if .mutineer.yml sets since:)"
53
+ end
38
54
 
39
55
  new(doc)
40
56
  rescue JSON::ParserError => e
@@ -45,39 +61,61 @@ module Mutineer
45
61
 
46
62
  # Builds a baseline from a JSON document.
47
63
  #
48
- # The baseline retains the survivor document and score from the JSON report.
64
+ # The baseline retains the survivor document, score, and scope marker from
65
+ # the JSON report. A report whose `summary.scoped` is true came from a
66
+ # `--since` run: its score covers only changed-line mutants, so later diffs
67
+ # must not compare a full-run score against it.
49
68
  #
50
69
  # @param doc [Hash] parsed JSON document.
51
70
  def initialize(doc)
52
71
  @survivors = doc["survivors"] || []
53
72
  @score = doc.dig("summary", "score")
73
+ # Strict literal true only: a malformed value (say the STRING "false" in a
74
+ # hand-edited baseline) must not silently disable the score-drop gate.
75
+ @scoped = doc.dig("summary", "scoped") == true
54
76
  end
55
77
 
56
78
  # Diff a current AggregateResult against this baseline by stable survivor id.
57
79
  # `epsilon` tolerates float jitter on the score (default 0.0 = any drop
58
80
  # gates).
59
81
  #
82
+ # `scoped: true` marks the current run as diff-scoped (`--since`): its score
83
+ # is computed over only the changed-line mutants, a different denominator
84
+ # from a full-run baseline, so comparing the two scores manufactures false
85
+ # regressions. A scoped diff keeps the new-survivor gate (stable ids compare
86
+ # fine across scopes) and still reports both scores, but never sets
87
+ # score_drop.
88
+ #
60
89
  # @param aggregate [Mutineer::AggregateResult] current results.
61
90
  # @param epsilon [Float] score-drop tolerance.
91
+ # @param scoped [Boolean] current run was diff-scoped (`--since`).
62
92
  # @return [Mutineer::Baseline::Delta] delta summary.
63
- def diff(aggregate, epsilon: 0.0)
93
+ def diff(aggregate, epsilon: 0.0, scoped: false)
64
94
  current = aggregate.surviving_mutants
65
95
  current_ids = current.map(&:id)
66
96
  baseline_ids = @survivors.map { |h| h["id"] }
67
97
 
68
98
  new_survivors = current.reject { |r| baseline_ids.include?(r.id) }
69
- fixed = @survivors.reject { |h| current_ids.include?(h["id"]) }
99
+ # Under a diff-scoped side an out-of-scope baseline survivor was never
100
+ # re-tested, so reporting it "fixed" would be false: empty is honest.
101
+ fixed = if scoped || @scoped
102
+ []
103
+ else
104
+ @survivors.reject { |h| current_ids.include?(h["id"]) }
105
+ end
70
106
 
71
107
  current_score = aggregate.mutation_score
72
108
  # nil-score discipline (mirrors Reporter#exit_code): a score absent on
73
109
  # either side cannot be compared. Skip the drop check, keep the new-
74
- # survivor check.
75
- score_drop = !@score.nil? && !current_score.nil? &&
76
- current_score < @score - epsilon
110
+ # survivor check. Same when EITHER side is diff-scoped (the current run
111
+ # via `scoped:`, or the stored baseline via its `summary.scoped` marker):
112
+ # the denominators differ, so the scores are not comparable.
113
+ comparable = !scoped && !@scoped && !@score.nil? && !current_score.nil?
114
+ score_drop = comparable && current_score < @score - epsilon
77
115
 
78
116
  Delta.new(new_survivors: new_survivors, fixed_survivors: fixed,
79
117
  score_before: @score, score_after: current_score,
80
- score_drop: score_drop,
118
+ score_drop: score_drop, score_comparable: comparable,
81
119
  regressed: !new_survivors.empty? || score_drop)
82
120
  end
83
121
  end
@@ -57,16 +57,24 @@ module Mutineer
57
57
 
58
58
  # Returns the stdout of `git -C <root> diff --unified=0 <ref> -- <file>`.
59
59
  #
60
+ # A failure is warned, never silent: an empty result means "no changed
61
+ # lines", which under `--since` removes every mutant for the file — a green
62
+ # gate must not be manufactured by a broken diff without a trace.
63
+ #
60
64
  # @param ref [String] git ref to diff against.
61
65
  # @param abs_file [String] absolute path of the file being diffed.
62
66
  # @param project_root [String] repository root for `git -C`.
63
- # @return [String] diff text, or `""` on failure.
67
+ # @return [String] diff text, or `""` on failure (after a stderr warning).
64
68
  def git_diff(ref, abs_file, project_root)
65
69
  out, _err, status = Open3.capture3(
66
70
  "git", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
67
71
  )
68
- status.success? ? out : ""
69
- rescue StandardError
72
+ return out if status.success?
73
+
74
+ warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
75
+ ""
76
+ rescue StandardError => e
77
+ warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
70
78
  ""
71
79
  end
72
80
  end
data/lib/mutineer/cli.rb CHANGED
@@ -41,6 +41,7 @@ module Mutineer
41
41
  --baseline-epsilon FLOAT Score-drop tolerance for --baseline (default: 0)
42
42
  --only NAME Restrict to one fully-qualified subject
43
43
  --since REF Only mutate lines changed since git REF (e.g. origin/main)
44
+ --no-since Disable diff scoping (a typed no beats a .mutineer.yml since:)
44
45
  --jobs N Parallel worker count (default: processor count)
45
46
  --strategy NAME reload (whole-file) or redefine (surgical); default: reload
46
47
  --framework NAME minitest or rspec (default: auto-detect from --test names)
@@ -96,6 +97,9 @@ module Mutineer
96
97
  o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
97
98
  o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
98
99
  o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
100
+ # A typed "no" must beat a .mutineer.yml `since:` key (CLI-over-config
101
+ # precedence): marking :since explicit with a nil value blocks the fill.
102
+ o.on("--no-since") { opts[:since] = nil; explicit << :since }
99
103
  o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
100
104
  o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
101
105
  o.on("--threshold FLOAT") do |v|
@@ -214,6 +218,11 @@ module Mutineer
214
218
  # environment, not weak tests. Runtime error (exit 1), not usage (exit 2).
215
219
  warn "mutineer: #{e.message}"
216
220
  exit 1
221
+ rescue Mutineer::ConcurrentRunError => e
222
+ # Another process owns a source file. Runtime error (exit 1), not a
223
+ # backtrace: the working tree is still the other run's responsibility.
224
+ warn "mutineer: #{e.message}"
225
+ exit 1
217
226
  rescue Mutineer::DaemonBootError => e
218
227
  # The daemon is gone for good, so the run ended rather than scoring the rest
219
228
  # against it. A deliberate stop deserves a message, not a raw backtrace.
@@ -465,11 +474,17 @@ module Mutineer
465
474
 
466
475
  # Diff the current run against the baseline (preflighted above) by the
467
476
  # stable survivor id. The delta is rendered inline (human section / additive
468
- # json block) and gates exit independently of --threshold.
469
- delta = (Baseline.load(config.baseline).diff(aggregate, epsilon: config.baseline_epsilon) if config.baseline)
477
+ # json block) and gates exit independently of --threshold. A --since run is
478
+ # scoped: its score covers a different denominator than a full-run baseline,
479
+ # so only the new-survivor half of the gate applies (see Baseline#diff).
480
+ delta = if config.baseline
481
+ Baseline.load(config.baseline).diff(aggregate, epsilon: config.baseline_epsilon,
482
+ scoped: !config.since.nil?)
483
+ end
470
484
 
471
485
  reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
472
- format: config.format, output: config.output, baseline: delta)
486
+ format: config.format, output: config.output, baseline: delta,
487
+ scoped: !config.since.nil?)
473
488
 
474
489
  # Warn (stderr, so it never pollutes json/html) that an external run's score
475
490
  # is not comparable to an in-process run: no coverage narrowing (uncovered
@@ -182,6 +182,11 @@ module Mutineer
182
182
  when "ignore" then Array(value).map(&:to_s)
183
183
  when "baseline" then value.to_s
184
184
  when "test_command" then value.to_s
185
+ when "since"
186
+ # false / empty normalize to nil ("no scoping"), so every consumer's
187
+ # nil-check (runner scoping, the report's scoped marker) agrees. A
188
+ # false left raw would skip scoping but still mark the report scoped.
189
+ value == false || value.to_s.empty? ? nil : value.to_s
185
190
  else value
186
191
  end
187
192
  end