mutineer 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. metadata +5 -1
@@ -15,10 +15,17 @@ module Mutineer
15
15
  # constant names, such as an `included` block that runs in each includer),
16
16
  # so the redefine strategy cannot load a mutant onto it. `hook_block` is true
17
17
  # for a def anywhere inside a concern's `included` or `prepended` block, which
18
- # ActiveSupport runs only when a class first includes the concern, so the
19
- # reload strategy cannot load a mutant onto it either (#256).
18
+ # ActiveSupport runs only when a class first includes the concern, so a
19
+ # reload of the file does not reach a class that already has it (#256).
20
+ # `reload_hook_call` is the `included` or `prepended` call node of such a
21
+ # block when reload can still place the def: the mutant file stores the
22
+ # mutated def just before that call, and the child loads it into each class
23
+ # that has the method (#297). It is nil when reload cannot place the def, even
24
+ # when `hook_block` is true (see docs/json-schema.md for the cases).
25
+ # `enclosing_subject` is the subject of the `def` whose body holds this one,
26
+ # or nil, so the redefine strategy can load each enclosing method too (#290).
20
27
  Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
21
- :hook_block, keyword_init: true) do
28
+ :hook_block, :reload_hook_call, :enclosing_subject, keyword_init: true) do
22
29
  # Returns the fully-qualified subject name.
23
30
  #
24
31
  # @return [String] namespaced method name like `Billing::Invoice#total`.
@@ -38,12 +45,13 @@ module Mutineer
38
45
  # call it, so the mutant is not run. Redefine needs a named owner. Reload
39
46
  # loads the whole file again, which redefines everything except what a
40
47
  # concern's `included` or `prepended` block defines: that block does not run
41
- # again in a class that already includes the concern.
48
+ # again in a class that already includes the concern. Reload places such a
49
+ # def only when discovery found its {#reload_hook_call} (#297).
42
50
  #
43
51
  # @param strategy [String] `"reload"` or `"redefine"`.
44
52
  # @return [Boolean]
45
53
  def unplaceable?(strategy)
46
- strategy == "redefine" ? owner_unknown == true : hook_block == true
54
+ strategy == "redefine" ? owner_unknown == true : hook_block == true && reload_hook_call.nil?
47
55
  end
48
56
 
49
57
  # Returns the body location for the subject, if any.
@@ -13,7 +13,8 @@ module Mutineer
13
13
  # first failing test.
14
14
  # @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
15
15
  # this channel and run every test.
16
- # @return [Integer] 0 on success, 1 on failure.
16
+ # @return [Integer] 0 on success, 1 on failure, 2 when the first failure
17
+ # was an Active Record connection or lock error.
17
18
  def self.run(test_files, stop_at_first_failure: false, record_to: nil)
18
19
  MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure, record_to: record_to)
19
20
  end
@@ -8,8 +8,10 @@ module Mutineer
8
8
  #
9
9
  # Each runner responds to
10
10
  # `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1/2`
11
- # (0 = all passed, 1 = any failure, 2 = only an error outside tests, which
12
- # the RSpec runner reports) and is called only inside a forked child.
11
+ # (0 = all passed, 1 = any failure, 2 = no verdict: an error outside tests,
12
+ # which the RSpec runner reports, or a Minitest run whose first failure was
13
+ # an Active Record connection or lock error, #296) and is called only inside
14
+ # a forked child.
13
15
  # Only the mutant path passes `stop_at_first_failure: true`; a `--matrix`
14
16
  # mutant run passes `record_to:` instead (a {KillChannel} pipe) and runs every
15
17
  # test. The fork boundary silences stdout (see ChildStdout); the runners do not.
@@ -92,7 +92,7 @@ module Mutineer
92
92
  return nil
93
93
  end
94
94
 
95
- doc = JSON.parse(File.read(path))
95
+ doc = JSON.parse(File.read(path, encoding: "UTF-8"))
96
96
  rows = doc.is_a?(Hash) ? doc["survivors"] : nil
97
97
  unless rows.is_a?(Array)
98
98
  err.puts "mutineer: triage report has no survivors list"
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.7.0"
5
+ VERSION = "1.8.0"
6
6
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "result"
4
+ require_relative "redaction"
4
5
 
5
6
  module Mutineer
6
7
  # Fixed-size fork pool. `run` forks up to `size` children at once; each child
@@ -74,7 +75,7 @@ module Mutineer
74
75
  begin
75
76
  yield(*items[idx])
76
77
  rescue Exception => e # rubocop:disable Lint/RescueException
77
- Result.error("worker crashed: #{e.class}: #{e.message}")
78
+ Result.error("worker crashed: #{Redaction.describe(e)}")
78
79
  end
79
80
  begin
80
81
  wr.write(Marshal.dump(payload))
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: mutineer
3
+ description: Mutation testing for Ruby (Prism + stdlib, zero deps). Mutates source one change at a time, runs the suite against each mutant, and reports the mutants tests fail to catch, with a versioned JSON contract and CI/agent gating. Use when checking whether a Ruby test suite would catch a change, finding weak or missing tests, gating CI on a mutation score, or acting on mutineer's JSON report.
4
+ ---
5
+
6
+ # Mutineer
7
+
8
+ Mutation testing for Ruby, built for CI gates and AI coding agents. Line coverage tells
9
+ you which code *ran*; mutation testing tells you whether your tests would *notice if it
10
+ broke*.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ gem install mutineer
16
+ ```
17
+
18
+ ## Run
19
+
20
+ ```sh
21
+ mutineer run <source...> [--test <test>...] [options] # no --test: pairs each source with its test by convention; unmatched sources are skipped; --dry-run with --rails/--boot needs --test
22
+ ```
23
+
24
+ Example — mutate `lib/calculator.rb`, gate CI below 90%:
25
+
26
+ ```sh
27
+ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
28
+ ```
29
+
30
+ ## Agent loop
31
+
32
+ 1. Edit code and tests on a branch. Fetch `origin/main` before using it below.
33
+ 2. Run diff-scoped, as JSON:
34
+ ```sh
35
+ mutineer run app/ --since origin/main --threshold 90 --format json --output .mutineer/run.json
36
+ ```
37
+ 3. Parse `survivors[]` — each carries a `diff` and an `id`. File moves, renames,
38
+ project-root changes, and changes to repeated-name or repeated-mutation order can
39
+ change ids; see [Mutant ids](https://github.com/davidteren/mutineer/blob/main/docs/results.md#mutant-ids). Review each change
40
+ against the intended behaviour. If review finds a test gap, write or strengthen
41
+ a test that fails under that change.
42
+ 4. Re-run. Accept the result only when this run exits `0`, `summary.score` is not `null`,
43
+ and `summary.score >= 90`. If every scored mutant must be killed, also require `summary.survived == 0`.
44
+ Mutineer 1.7.0 and earlier gate on the rounded score, so a run just below the threshold can
45
+ still exit `0`. On those versions, also require
46
+ `summary.killed * 100 >= 90 * (summary.killed + summary.survived)`.
47
+
48
+ Stop after a few unsuccessful attempts and ask a person to review the survivor.
49
+ Failed attempts do not prove that a mutant is equivalent. To
50
+ suppress it, `mutineer triage .mutineer/run.json --reason TEXT --id ID` prints an
51
+ `ignore:` entry for `.mutineer.yml` (Mutineer 1.7 or later reads it). See
52
+ [Suppressing equivalent mutants](https://github.com/davidteren/mutineer/blob/main/docs/results.md#suppressing-equivalent-mutants).
53
+
54
+ Keep `fail_fast` disabled (the default). If the active `.mutineer.yml` sets it to
55
+ `true`, temporarily change that value to `false` for the loop. Restore the original
56
+ value when the loop ends, before committing. A fail-fast run is partial,
57
+ and its score and exit code do not cover the full scope.
58
+
59
+ Zero survivors alone is not success: an empty or fully suppressed scope, or a run
60
+ with no usable verdicts, also has zero survivors. If the score is null, stop and
61
+ report “no score”. Check `no_coverage[]` for test gaps and `no_verdict[]` for
62
+ harness failures, and fix them before starting a new run. A positive threshold fails when nothing can be scored and something
63
+ broke, or when more than one mutant has no verdict and they exceed 10% of those
64
+ attempted; it does not require zero errors. Set the threshold to your target.
65
+
66
+ An empty run (`summary.total` is 0) exits 0 in 1.x and prints a warning on stderr. Treat it as "no score". Pass `--allow-empty` only when an empty run is expected. A `--since` run whose changes hold no mutable code says "nothing to test in the changes since REF"; that is a valid success. Warnings that start with `mutineer:` or `[mutineer]` also go to stderr: the empty-run warning, "did you mean" hints, notes about Mutineer 2.0, the count of errored mutants, and a notice that a `PARALLEL_WORKERS` value you set was replaced with 1.
67
+
68
+ ## Exit codes
69
+
70
+ <!-- contract:exit-codes -->
71
+ | Code | Meaning |
72
+ |------|---------|
73
+ | `0` | Score ≥ threshold (or no gate) **and** no baseline regression. |
74
+ | `1` | Score below `--threshold`, OR nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted, OR a `--baseline` regression, OR a runtime error, including a boot, source or `require:` file that raises while loading. |
75
+ | `2` | Usage / invalid-flag error, including a mistyped flag, a bad path (such as a missing `--boot` or `require:` file), or an unreadable baseline. |
76
+ <!-- /contract:exit-codes -->
77
+
78
+ ## References
79
+
80
+ - Docs: https://davidteren.github.io/mutineer/
81
+ - Agent & CI guide: https://davidteren.github.io/mutineer/agentic-coding.html
82
+ - JSON schema: https://davidteren.github.io/mutineer/json-schema.html
83
+ - Ruby API: https://davidteren.github.io/mutineer/api/
84
+ - Source: https://github.com/davidteren/mutineer
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: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -72,6 +72,7 @@ files:
72
72
  - lib/mutineer/kill_matrix.rb
73
73
  - lib/mutineer/migrate.rb
74
74
  - lib/mutineer/minitest_integration.rb
75
+ - lib/mutineer/minitest_integration/in_test_database_error.rb
75
76
  - lib/mutineer/minitest_integration/kill_recorder.rb
76
77
  - lib/mutineer/minitest_integration/outer_reporter.rb
77
78
  - lib/mutineer/minitest_integration/stop_at_first_failure.rb
@@ -119,6 +120,7 @@ files:
119
120
  - lib/mutineer/triage.rb
120
121
  - lib/mutineer/version.rb
121
122
  - lib/mutineer/worker_pool.rb
123
+ - skills/mutineer/SKILL.md
122
124
  homepage: https://github.com/davidteren/mutineer
123
125
  licenses:
124
126
  - MIT
@@ -128,6 +130,8 @@ metadata:
128
130
  bug_tracker_uri: https://github.com/davidteren/mutineer/issues
129
131
  documentation_uri: https://davidteren.github.io/mutineer/
130
132
  rubygems_mfa_required: 'true'
133
+ hyperdrive_targets: "*"
134
+ hyperdrive_artifacts: skill
131
135
  rdoc_options: []
132
136
  require_paths:
133
137
  - lib