mutation_tester 1.5.1 → 1.6.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.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../test_recorder'
4
+
5
+ module MutationTester
6
+ module TestRecorder
7
+ module RSpecHook
8
+ STATUSES = { passed: 'passed', failed: 'failed', pending: 'skipped' }.freeze
9
+
10
+ class << self
11
+ def install
12
+ return if @installed
13
+
14
+ @installed = true
15
+ ::RSpec.configure do |config|
16
+ config.after(:suite) { MutationTester::TestRecorder::RSpecHook.flush }
17
+ end
18
+ end
19
+
20
+ def flush
21
+ return unless TestRecorder.active?
22
+
23
+ TestRecorder.write(all_examples.filter_map { |example| entry_for(example) })
24
+ rescue StandardError => e
25
+ Kernel.warn "[MutationTester] The kill matrix could not record the rspec results: #{e.class}: #{e.message}"
26
+ end
27
+
28
+ private
29
+
30
+ def all_examples
31
+ ::RSpec.world.example_groups.flat_map(&:descendants).flat_map(&:examples)
32
+ end
33
+
34
+ def entry_for(example)
35
+ status = STATUSES[example.execution_result.status]
36
+ return nil unless status
37
+
38
+ metadata = example.metadata
39
+ {
40
+ id: id_for(metadata),
41
+ name: example.full_description,
42
+ line: metadata[:file_path] == rerun_path(metadata) ? metadata[:line_number] : nil,
43
+ status: status
44
+ }
45
+ end
46
+
47
+ def id_for(metadata)
48
+ path = TestRecorder.relative_to_root(File.expand_path(rerun_path(metadata)))
49
+ return "#{path}[#{metadata[:scoped_id]}]" if metadata[:scoped_id]
50
+
51
+ "#{path}:#{metadata[:line_number]}"
52
+ end
53
+
54
+ def rerun_path(metadata)
55
+ metadata[:rerun_file_path] || metadata[:file_path]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ MutationTester::TestRecorder::RSpecHook.install
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tempfile'
5
+
6
+ module MutationTester
7
+ module TestRecorder
8
+ LOG_ENV = 'MUTATION_TESTER_TEST_LOG'
9
+ SCOPE_ENV = 'MUTATION_TESTER_TEST_LOG_SCOPE'
10
+ ROOT_ENV = 'MUTATION_TESTER_TEST_LOG_ROOT'
11
+ FAILED = 'failed'
12
+ RSPEC_HOOK_PATH = File.expand_path('test_recorder/rspec_hook.rb', __dir__).freeze
13
+ MINITEST_HOOK_PATH = File.expand_path('test_recorder/minitest_hook.rb', __dir__).freeze
14
+
15
+ class << self
16
+ def capture(scope:, root:)
17
+ log = Tempfile.new(['mutation_tester_tests', '.jsonl'])
18
+ log.close
19
+ outcome = yield(LOG_ENV => log.path, SCOPE_ENV => scope.to_s, ROOT_ENV => root.to_s)
20
+ [outcome, read(log.path)]
21
+ ensure
22
+ log&.unlink
23
+ end
24
+
25
+ def read(path)
26
+ return [] unless File.exist?(path)
27
+
28
+ File.readlines(path).filter_map do |line|
29
+ JSON.parse(line, symbolize_names: true)
30
+ rescue JSON::ParserError
31
+ nil
32
+ end
33
+ end
34
+
35
+ def failed_ids(entries)
36
+ Array(entries).select { |entry| entry[:status] == FAILED }.map { |entry| entry[:id] }.uniq.sort
37
+ end
38
+
39
+ def active?
40
+ !ENV[LOG_ENV].to_s.empty?
41
+ end
42
+
43
+ def write(entries)
44
+ return unless active?
45
+
46
+ kept = ENV[SCOPE_ENV] == 'failures' ? entries.select { |entry| entry[:status] == FAILED } : entries
47
+ return if kept.empty?
48
+
49
+ File.open(ENV[LOG_ENV], 'a') do |file|
50
+ kept.each { |entry| file.puts(JSON.generate(entry)) }
51
+ end
52
+ end
53
+
54
+ def relative_to_root(path)
55
+ root = ENV[ROOT_ENV].to_s
56
+ return path if root.empty?
57
+
58
+ prefixes = [root, resolved(root)].uniq.map { |candidate| "#{candidate.chomp('/')}/" }
59
+ [path, resolved(path)].uniq.each do |candidate|
60
+ prefix = prefixes.find { |value| candidate.start_with?(value) }
61
+ return candidate.delete_prefix(prefix) if prefix
62
+ end
63
+ path
64
+ end
65
+
66
+ private
67
+
68
+ def resolved(path)
69
+ File.realpath(path)
70
+ rescue SystemCallError
71
+ path
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,3 @@
1
1
  module MutationTester
2
- VERSION = '1.5.1'.freeze
2
+ VERSION = '1.6.0'.freeze
3
3
  end
@@ -5,6 +5,7 @@ require 'rainbow'
5
5
  require 'fileutils'
6
6
  require 'erb'
7
7
  require 'json'
8
+ require 'io/console'
8
9
 
9
10
  require_relative 'mutation_tester/version'
10
11
  require_relative 'mutation_tester/configuration'
data/readme.md CHANGED
@@ -55,6 +55,7 @@ interruption line (no backtrace), and exits with status 130.
55
55
  - [Mutation types](#mutation-types)
56
56
  - [Equivalent mutants](#equivalent-mutants)
57
57
  - [Reports and output](#reports-and-output)
58
+ - [Finding redundant tests](#finding-redundant-tests)
58
59
  - [Pre-push hook](#pre-push-hook)
59
60
  - [CI/CD integration](#cicd-integration)
60
61
  - [Troubleshooting](#troubleshooting)
@@ -294,6 +295,7 @@ expected path printed.
294
295
  | `--spec-map RULE` | Spec-mapping regex rule `'PATTERN=>REPLACEMENT'`; repeatable, first match wins. See [Mapping sources to specs](#mapping-sources-to-specs). |
295
296
  | `--minimum-score N` | Mutation score percentage a file must reach to pass (default: 80). Drives the `PASS`/`FAIL` verdict and the exit code. |
296
297
  | `--fail-fast` | Stop the run at the first surviving mutant and finish with a failing status. |
298
+ | `--kill-matrix` | Audit mode: run every mutant against the full test file and record which tests kill it (`mutations[].killed_by`, `tests[]`). Slower; cannot be combined with `--fail-fast`. See [Finding redundant tests](#finding-redundant-tests). |
297
299
  | `--timeout-factor N` | Per-mutant timeout budget as `N` times the measured baseline test run, never below 5 s (default: 5, must be > 0). Ignored when `config.timeout` is set explicitly. |
298
300
  | `--timeout-policy MODE` | Scoring policy for timed-out mutants: `killed` (default) counts a timeout as a kill; `separate` keeps timeouts out of the score and reports them as their own category. |
299
301
  | `--worker-env NAME` | Per-worker database isolation via the `parallel_tests` `TEST_ENV_NUMBER` convention. See [Making parallelism work with Rails](#making-parallelism-work-with-rails). |
@@ -304,7 +306,7 @@ expected path printed.
304
306
  | `--reporters LIST` | Comma-separated reporters to run: `console`, `html`, `json` (default: all three). An unknown name errors and exits 1. |
305
307
  | `--output-dir PATH` | Directory for the generated report files (default: `tmp/mutation_reports`). In batch mode each file writes to its own subdirectory. |
306
308
  | `--verbose` | Show a per-mutation warning for every skipped mutation (quiet by default; the "Generated N mutations, skipped M" summary always prints when mutants are dropped). |
307
- | `--no-progress` | Disable progress display. |
309
+ | `--no-progress` | Disable the live progress line (percentage, processed count, elapsed time, estimated remaining time, survived and timed out tallies, and an errored tally once a mutant errors). |
308
310
  | `-h, --help` | Show help message. |
309
311
  | `-v, --version` | Show version. |
310
312
 
@@ -404,6 +406,11 @@ MutationTester.configure do |config|
404
406
  # survived. Set to false to always run the full file. See Execution model.
405
407
  config.test_selection = true
406
408
 
409
+ # Kill matrix (audit mode): run every mutant against the full test file and
410
+ # record in the JSON report which tests kill it. Slower than a normal run.
411
+ # Equivalent to the --kill-matrix CLI flag. See Finding redundant tests.
412
+ config.kill_matrix = false
413
+
407
414
  # Hard deadline for the single baseline run of the whole suite (seconds).
408
415
  # Runs every example once, so it is looser than the per-mutant timeout above.
409
416
  # Set to nil to disable the baseline deadline.
@@ -622,6 +629,9 @@ reads). Such a mutant used to pay the full test file once per mutant, which on a
622
629
  per-mutant deadline and turn a decided kill into a reported timeout. The baseline run and the shadow sanity check are
623
630
  unaffected: they are expected to pass, and a passing run runs every test.
624
631
 
632
+ The one exception is the opt-in `--kill-matrix` audit mode, which needs every failing test of every mutant and
633
+ therefore runs the full test file each time. See [Finding redundant tests](#finding-redundant-tests).
634
+
625
635
  ### Test selection (fast kill with full-file confirmation)
626
636
 
627
637
  For RSpec suites on the file-based runners, MutationTester runs each mutant in two phases: it first runs only the
@@ -751,6 +761,79 @@ gap: the worklist you can hand to an AI agent or a CI gate (see [CI/CD integrati
751
761
  Full field-by-field documentation of both shapes (the single-file report and the multi-file envelope), the
752
762
  `schema_version` policy, and ready-to-use `jq` recipes live in [docs/json-schema.md](docs/json-schema.md).
753
763
 
764
+ ## Finding redundant tests
765
+
766
+ Mutation testing normally answers "which behavior is untested?". The opt-in **kill matrix** answers the opposite
767
+ question: "which tests could I delete without losing any protection?". A test is a redundancy candidate when removing
768
+ it leaves the set of killed mutants unchanged.
769
+
770
+ ```bash
771
+ # One file
772
+ bundle exec mutation_test --kill-matrix --json lib/calculator.rb > kill_matrix.json
773
+
774
+ # A whole directory (one aggregate JSON document)
775
+ bundle exec mutation_test --kill-matrix --json --glob 'lib/**/*.rb' > kill_matrix.json
776
+ ```
777
+
778
+ `--kill-matrix` (or `config.kill_matrix = true`) changes how every mutant is run and what the JSON report contains. It
779
+ works the same for RSpec and Minitest and on all three runners:
780
+
781
+ - Every mutant runs the **full** test file. The run does not stop at the first failing test and RSpec test selection
782
+ is off, because a run that stops early would name only one of the killers.
783
+ - `tests[]` lists every test of the unmutated baseline run (`id`, `name`, `line`, `status`), so a test that kills
784
+ nothing is still visible.
785
+ - `mutations[].killed_by` lists the `id` of every test that failed under that mutant.
786
+
787
+ Test ids are `TestClass#test_name` for Minitest and `spec/calculator_spec.rb[1:2:1]` for RSpec, which you can pass
788
+ straight to `rspec` from the project root to run that one example (RSpec older than 3.3 has no such ids and gets
789
+ `spec/calculator_spec.rb:12`, file and line, instead).
790
+
791
+ The analysis itself is a short `jq` program over the report. Both recipes work on a single-file report and on the
792
+ multi-file envelope.
793
+
794
+ ```bash
795
+ # 1. Tests that kill no mutant at all
796
+ jq -r '(.files // [.])[]
797
+ | [.mutations[].killed_by[]] as $killers
798
+ | .tests[] | select(.status == "passed" and (.id | IN($killers[]) | not))
799
+ | "\(.id)\t\(.name)"' kill_matrix.json
800
+
801
+ # 2. Tests whose every kill is shared: each mutant they kill is also killed by another test
802
+ jq -r '(.files // [.])[]
803
+ | [.mutations[] | select(.status == "killed") | .killed_by] as $kills
804
+ | .tests[] | .id as $id
805
+ | [$kills[] | select(index($id))] as $mine
806
+ | select(($mine | length) > 0 and all($mine[]; length > 1))
807
+ | "\(.id)\t\(.name)"' kill_matrix.json
808
+
809
+ # 3. Mutants whose killers are unknown (read the results with care when this is not 0)
810
+ jq '[(.files // [.])[] | .mutations[]
811
+ | select((.status == "killed" or .status == "timeout") and (.killed_by | length) == 0)] | length' kill_matrix.json
812
+ ```
813
+
814
+ Read the lists as candidates to review, not as a delete list:
815
+
816
+ - **Remove one test at a time and re-run.** Two tests from list 2 can cover for each other: each one is redundant
817
+ while the other exists, but deleting both lets their mutants survive.
818
+ - The result is relative to **one source file and the enabled mutation types**. A test that kills nothing here may
819
+ protect another file, or behavior no mutation operator changes (for example the exact wording of a message when the
820
+ `string` mutations are disabled).
821
+ - A `timeout` mutant always has an empty `killed_by`, and so does a `killed` mutant that failed without any test
822
+ failing (typically the mutated file no longer loads). Their killers are unknown, so a test must never be called
823
+ redundant because of them. Recipe 3 counts them; a higher `--timeout-factor` usually turns timeouts into real kills.
824
+ - A skipped test has `"status": "skipped"` in `tests[]` and is left out of the recipes.
825
+ - If the baseline passes but not a single test could be recorded (the test file defines no tests, or a plugin replaces
826
+ the framework's reporters), the run stops with an error instead of reporting an empty matrix.
827
+
828
+ The mode is an occasional audit, not a gate. Without the early stop a mutant that breaks something every test touches
829
+ pays for the whole test file, so expect a slower run and consider `--timeout-factor 10`. It cannot be combined with
830
+ `--fail-fast` (or `config.fail_fast`), which would stop the run at the first surviving mutant and leave the matrix
831
+ incomplete: the CLI rejects the pair as a usage error and a run configured from Ruby or rake fails before the baseline.
832
+
833
+ For a scheduled CI job that publishes the candidates, see
834
+ [`examples/github_actions/redundant_tests.yml`](examples/github_actions/redundant_tests.yml) and
835
+ [docs/ci.md](docs/ci.md#redundant-test-audit-scheduled-job).
836
+
754
837
  ## Pre-push hook
755
838
 
756
839
  Gate your pushes locally: run mutation testing on the file(s) you touched and block the push when the score is under
@@ -801,6 +884,9 @@ The gem ships ready-to-copy GitHub Actions workflows (installed alongside the ge
801
884
  - [`examples/github_actions/ai_mutation_gate.yml`](examples/github_actions/ai_mutation_gate.yml) is the AI gate: the
802
885
  same pass/fail gate, plus it writes the surviving-mutant worklist to the GitHub job summary and uploads
803
886
  `survivors.json` for an agent to turn into missing tests.
887
+ - [`examples/github_actions/redundant_tests.yml`](examples/github_actions/redundant_tests.yml) is a scheduled audit,
888
+ not a gate: it runs `--kill-matrix` and lists the tests that kill no mutant, or no mutant of their own, in the job
889
+ summary. See [Finding redundant tests](#finding-redundant-tests).
804
890
 
805
891
  See [docs/ci.md](docs/ci.md) for the full recipes: a 5-minute setup, minimal inline and pull-request workflows, machine
806
892
  mode as a gate and artifact, and the AI workflow.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutation_tester
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kamil Dzierbicki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: parallel
@@ -153,6 +153,7 @@ files:
153
153
  - examples/calculator_spec.rb
154
154
  - examples/github_actions/ai_mutation_gate.yml
155
155
  - examples/github_actions/mutation_test.yml
156
+ - examples/github_actions/redundant_tests.yml
156
157
  - examples/hooks/pre-push
157
158
  - examples/run_example.rb
158
159
  - examples/run_example_minitest.rb
@@ -170,6 +171,7 @@ files:
170
171
  - lib/mutation_tester/framework_detector.rb
171
172
  - lib/mutation_tester/in_memory_loader.rb
172
173
  - lib/mutation_tester/minitest_fail_fast.rb
174
+ - lib/mutation_tester/minitest_load_hook.rb
173
175
  - lib/mutation_tester/mutation_runner.rb
174
176
  - lib/mutation_tester/mutator.rb
175
177
  - lib/mutation_tester/progress_display.rb
@@ -181,6 +183,9 @@ files:
181
183
  - lib/mutation_tester/reporters/html_reporter.rb
182
184
  - lib/mutation_tester/reporters/json_reporter.rb
183
185
  - lib/mutation_tester/test_command.rb
186
+ - lib/mutation_tester/test_recorder.rb
187
+ - lib/mutation_tester/test_recorder/minitest_hook.rb
188
+ - lib/mutation_tester/test_recorder/rspec_hook.rb
184
189
  - lib/mutation_tester/version.rb
185
190
  - lib/tasks/mutation_tester.rake
186
191
  - readme.md