mutation_tester 1.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +83 -0
  5. data/LICENSE.txt +19 -0
  6. data/Rakefile +57 -0
  7. data/docs/ci.md +158 -0
  8. data/docs/execution-runners.md +163 -0
  9. data/docs/json-schema.md +171 -0
  10. data/docs/mutation-types.md +207 -0
  11. data/examples/calculator.rb +35 -0
  12. data/examples/calculator_100_perc_spec.rb +121 -0
  13. data/examples/calculator_minitest.rb +53 -0
  14. data/examples/calculator_spec.rb +105 -0
  15. data/examples/github_actions/ai_mutation_gate.yml +75 -0
  16. data/examples/github_actions/mutation_test.yml +87 -0
  17. data/examples/hooks/pre-push +41 -0
  18. data/examples/run_example.rb +31 -0
  19. data/examples/run_example_minitest.rb +38 -0
  20. data/examples/run_example_parallel_minitest.rb +48 -0
  21. data/examples/run_example_parallel_rspec.rb +48 -0
  22. data/examples/run_example_rspec.rb +38 -0
  23. data/examples/spec_helper.rb +21 -0
  24. data/exe/mutation_test +345 -0
  25. data/lib/mutation_tester/batch_runner.rb +286 -0
  26. data/lib/mutation_tester/configuration.rb +105 -0
  27. data/lib/mutation_tester/core.rb +193 -0
  28. data/lib/mutation_tester/fork_runner/worker.rb +202 -0
  29. data/lib/mutation_tester/fork_runner.rb +382 -0
  30. data/lib/mutation_tester/framework_detector.rb +25 -0
  31. data/lib/mutation_tester/in_memory_loader.rb +25 -0
  32. data/lib/mutation_tester/mutation_runner.rb +644 -0
  33. data/lib/mutation_tester/mutator.rb +874 -0
  34. data/lib/mutation_tester/progress_display.rb +130 -0
  35. data/lib/mutation_tester/railtie.rb +7 -0
  36. data/lib/mutation_tester/rake_task.rb +18 -0
  37. data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
  38. data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
  39. data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
  40. data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
  41. data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
  42. data/lib/mutation_tester/test_command.rb +165 -0
  43. data/lib/mutation_tester/version.rb +3 -0
  44. data/lib/mutation_tester.rb +52 -0
  45. data/lib/tasks/mutation_tester.rake +80 -0
  46. data/readme.md +925 -0
  47. metadata +213 -0
@@ -0,0 +1,171 @@
1
+ # JSON Report Schema
2
+
3
+ Field-by-field documentation of the machine-readable JSON that `mutation_test`
4
+ produces (written to `mutation_report.json` and, with `--json`, streamed to
5
+ stdout). See the [Reports and output](../readme.md#reports-and-output) section of
6
+ the README for the stdout/stderr contract and the quick-start `jq` recipes.
7
+
8
+ A run that resolves exactly one positional source file, or an explicit
9
+ `SOURCE_FILE TEST_FILE` pair, prints the plain per-file report documented in
10
+ [Single-file report](#single-file-report-schema_version-1). A multi-file run (a
11
+ `FILE` list with more than one file, `--staged`, or `--glob`) prints one
12
+ aggregate envelope instead; see
13
+ [Multi-file runs: the aggregate envelope](#multi-file-runs-the-aggregate-envelope).
14
+
15
+ ## Schema versioning
16
+
17
+ The report carries a top-level `schema_version` (currently `1`). It is bumped on
18
+ any **incompatible** change to the shape (a removed or renamed field, or a
19
+ changed type or meaning); purely additive fields do not bump it. Read
20
+ `schema_version` before relying on the payload, and see the CHANGELOG for the
21
+ history of changes.
22
+
23
+ ## Single-file report (schema_version 1)
24
+
25
+ ```json
26
+ {
27
+ "schema_version": 1,
28
+ "interrupted": false,
29
+ "metadata": {
30
+ "version": "1.0.0",
31
+ "generated_at": "2024-01-15T10:30:00Z",
32
+ "source_file": "app/models/user.rb"
33
+ },
34
+ "summary": {
35
+ "total": 20,
36
+ "killed": 18,
37
+ "survived": 2,
38
+ "mutation_score": 90.0
39
+ },
40
+ "mutations": [
41
+ ...
42
+ ]
43
+ }
44
+ ```
45
+
46
+ | Field | Type | Description |
47
+ |--------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
48
+ | `schema_version` | integer | Version of this schema. Start value `1`. |
49
+ | `interrupted` | boolean | Additive (does not bump `schema_version`). `true` only when `--fail-fast` stopped the run before every generated mutation was processed, so the report is partial; `false` for a complete run, including a run whose first surviving mutant was the last mutation. |
50
+ | `metadata.version` | string | Version of the `mutation_tester` gem that produced the report. |
51
+ | `metadata.generated_at` | string | ISO 8601 timestamp of when the report was generated. |
52
+ | `metadata.source_file` | string | Absolute path of the mutated source file. |
53
+ | `metadata.spec_file` | string | Absolute path of the test file that was run. |
54
+ | `summary.total` | integer | Total number of mutations produced (all statuses). |
55
+ | `summary.killed` | integer | Effective kills (a `timeout` counts as a kill). Kept for backward compatibility. |
56
+ | `summary.survived` | integer | Number of surviving mutants. |
57
+ | `summary.mutation_score` | number | Percentage `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals. `stillborn` and `error` are excluded from the denominator. |
58
+ | `summary.quality_rating` | string | Human label derived from the score (Excellent/Good/Fair/Poor/Critical). |
59
+ | `summary.categories.killed` | integer | Mutants whose covering tests failed. |
60
+ | `summary.categories.survived` | integer | Mutants whose covering tests still passed (a test gap candidate). |
61
+ | `summary.categories.timeout` | integer | Mutants that exceeded the per-mutant deadline (counted as kills in the score). |
62
+ | `summary.categories.stillborn` | integer | Mutants whose code no longer parses. Never run; excluded from the score. |
63
+ | `summary.categories.error` | integer | Mutants that hit a runner-side error. Excluded from the score. |
64
+ | `mutations[].id` | integer | Stable id of the mutation within this run. |
65
+ | `mutations[].type` | string | Mutation category (e.g. `arithmetic`, `boolean`, `comparison`, `call_removal`, `nil_injection`, `argument`, `strict_equality` when the opt-in mode is enabled). |
66
+ | `mutations[].line` | integer | 1-based line number of the mutated code. |
67
+ | `mutations[].file_path` | string | Absolute path of the mutated source file. |
68
+ | `mutations[].original` | string | The original operator/literal that was mutated; for `call_removal`, the removed call expression (e.g. `items.uniq`); for `nil_injection`, the replaced expression (e.g. `self`); for `argument`, the full original call (e.g. `raise ArgumentError, msg`) or, for a default-value mutant, the original parameter (e.g. `opts = {}`). |
69
+ | `mutations[].mutated` | string | The replacement operator/literal; for `call_removal`, the bare receiver (e.g. `items`); for `nil_injection`, always `nil`; for `argument`, the call after the mutation (e.g. `raise ArgumentError`) or, for a default-value mutant, the parameter after the mutation (e.g. `opts` or `opts = nil`). |
70
+ | `mutations[].source_line` | string | The original source line. |
71
+ | `mutations[].mutated_line` | string | The source line after mutation. |
72
+ | `mutations[].killed` | boolean | Legacy passthrough flag from the runner (`true` for `killed` and `timeout`). Kept for backward compatibility; prefer `status`. |
73
+ | `mutations[].timeout` | boolean | Legacy passthrough flag from the runner (`true` only when the mutant timed out). Kept for backward compatibility; prefer `status`. |
74
+ | `mutations[].status` | string | One of the taxonomy statuses below. This is the authoritative per-mutant result. |
75
+ | `mutations[].description` | string | Human-readable description of the mutation (for `error`, the failure message). |
76
+ | `mutations[].diff` | string | Optional, additive (does not bump `schema_version`). Present only for `survived` and `timeout` mutants: a unified diff of the change with a few lines of surrounding context (`@@` hunk header, lines prefixed with `- `, `+ ` or two spaces). Falls back to a context-free `- `/`+ ` pair when the source file is not readable at report time. |
77
+
78
+ **Status taxonomy** (`mutations[].status`): `killed` (tests caught it), `survived`
79
+ (tests missed it), `timeout` (ran too long, scored as a kill), `stillborn`
80
+ (unparseable, excluded from the score), `error` (runner failure, excluded from
81
+ the score).
82
+
83
+ ## Extracting the score and the survived mutants
84
+
85
+ A ready-to-use snippet for an agent or script: read the score and list every
86
+ surviving mutant with its location.
87
+
88
+ ```bash
89
+ # Overall score and per-status breakdown
90
+ bundle exec mutation_test --json examples/calculator.rb examples/calculator_spec.rb \
91
+ | jq '{score: .summary.mutation_score, categories: .summary.categories}'
92
+
93
+ # Just the survivors: file, line and what changed (the test gaps to close)
94
+ bundle exec mutation_test --json examples/calculator.rb examples/calculator_spec.rb \
95
+ | jq '[.mutations[] | select(.status == "survived")
96
+ | {file: .file_path, line: .line, from: .original, to: .mutated}]'
97
+ ```
98
+
99
+ ## Multi-file runs: the aggregate envelope
100
+
101
+ With `--json` on a multi-file run (a `FILE` list with more than one file,
102
+ `--staged`, or `--glob`) stdout carries exactly one JSON document: an envelope
103
+ with a top-level `schema_version`, a run summary, a condensed `survivors` array,
104
+ and the full per-file reports. All diagnostics and progress stay on stderr, and
105
+ the exit code keeps its meaning (`0` passed, `1` failed).
106
+
107
+ ```json
108
+ {
109
+ "schema_version": 1,
110
+ "summary": {
111
+ "files": 3,
112
+ "processed": 2,
113
+ "skipped": [
114
+ {
115
+ "file": "lib/orphan.rb",
116
+ "reason": "no matching spec file"
117
+ }
118
+ ],
119
+ "score": 83.33,
120
+ "passed": false,
121
+ "interrupted": false
122
+ },
123
+ "survivors": [
124
+ {
125
+ "file": "/home/you/project/lib/calc.rb",
126
+ "line": 4,
127
+ "type": "arithmetic",
128
+ "original": "+",
129
+ "mutated": "-"
130
+ }
131
+ ],
132
+ "files": [
133
+ {
134
+ "schema_version": 1,
135
+ "...": "one full per-file report per processed file"
136
+ }
137
+ ]
138
+ }
139
+ ```
140
+
141
+ | Field | Type | Description |
142
+ |------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
143
+ | `schema_version` | integer | Version of the envelope schema. Start value `1`. Distinct from the per-file `files[].schema_version`: the envelope and the per-file report version independently. |
144
+ | `summary.files` | integer | Files that entered the run: processed plus skipped. Files a `--since` filter left out as unchanged are not counted; they are reported on stderr. |
145
+ | `summary.processed` | integer | Files that were actually mutation-tested. |
146
+ | `summary.skipped` | array | One entry per skipped file: `file` (the path as given) and `reason`, one of `file not found`, `not a Ruby source file`, `a test file, not a mutable source`, `no matching spec file`. |
147
+ | `summary.score` | number | Aggregate mutation score over every mutant of every processed file, with the per-file formula: `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals; `stillborn` and `error` are excluded from the denominator. |
148
+ | `summary.passed` | boolean | `true` exactly when the process exits `0`: the run matched/processed files and every processed file met the threshold. |
149
+ | `summary.interrupted` | boolean | `true` when `--fail-fast` stopped the batch at a surviving mutant, so remaining files were not run and the envelope is partial. |
150
+ | `survivors[].file` | string | Absolute path of the mutated source file, the same convention as `files[].metadata.source_file`, so a survivor joins its full per-file report by an exact string match on this value. |
151
+ | `survivors[].line` | integer | 1-based line number of the mutated code. |
152
+ | `survivors[].type` | string | Mutation category, same values as `mutations[].type`. |
153
+ | `survivors[].original` | string | The original code fragment. |
154
+ | `survivors[].mutated` | string | The fragment after the mutation. |
155
+ | `files[]` | object | One complete per-file report per processed file, exactly the single-file schema above: `schema_version`, `interrupted`, `metadata`, `summary`, and `mutations` with the five statuses `killed`, `survived`, `timeout`, `stillborn`, `error`. |
156
+
157
+ `survivors` is the agent-facing view: every mutant the tests failed to detect,
158
+ with just the fields needed to decide which test to add and where, so there is
159
+ no need to descend into `files[]` for the common loop.
160
+
161
+ ```bash
162
+ # The test gaps to close, straight from the staging area
163
+ bundle exec mutation_test --staged --json | jq '.survivors'
164
+
165
+ # Join a survivor to its full per-file report: survivors[].file == files[].metadata.source_file
166
+ bundle exec mutation_test --staged --json \
167
+ | jq '.survivors[0] as $s | .files[] | select(.metadata.source_file == $s.file)'
168
+
169
+ # Gate a script on the aggregate outcome (exit code of jq -e follows .summary.passed)
170
+ bundle exec mutation_test --staged --json | jq -e '.summary.passed'
171
+ ```
@@ -0,0 +1,207 @@
1
+ # Mutation Types
2
+
3
+ The full catalog of mutation families MutationTester generates. Ten families are
4
+ enabled by default; strict-equality is opt-in. See the
5
+ [Mutation types](../readme.md#mutation-types) section of the README for how to
6
+ enable or disable individual families via `config.mutation_types` or the
7
+ `--strict-equality` flag. The [Equivalent mutants](#equivalent-mutants) section
8
+ below covers how to interpret and suppress survivors.
9
+
10
+ ## Arithmetic Mutations
11
+
12
+ - `+` → `-`, `*`, `/`
13
+ - `-` → `+`, `*`, `/`
14
+ - `*` → `+`, `-`, `/`
15
+ - `/` → `+`, `-`, `*`
16
+ - `%` → `+`, `-`, `*`
17
+ - `**` → `*`, `+` (only these two: `-`/`/` tend to produce hard-to-kill or `ZeroDivisionError` mutants)
18
+ - Compound assignment (`+=`, `-=`, `*=`, `/=`, `%=`, `**=`) mutates its operator the same way (e.g. `a **= b` →`a *= b`,
19
+ `a += b`).
20
+
21
+ ## Bitwise Compound-Assignment Mutations
22
+
23
+ - `|=` → `&=`
24
+ - `&=` → `|=`
25
+ - `^=` → `|=`, `&=`
26
+
27
+ Plain bitwise sends (`a | b`, `a & b`, `a ^ b`), and the shift assignments `<<=` / `>>=`, are intentionally left alone:
28
+ mutating them mostly yields always-killed `NoMethodError` garbage rather than a meaningful test gap.
29
+
30
+ ## Comparison Mutations
31
+
32
+ - `>` → `<`, `>=`, `<=`, `==`
33
+ - `<` → `>`, `>=`, `<=`, `==`
34
+ - `>=` → `<`, `>`, `<=`, `==`
35
+ - `<=` → `>`, `>=`, `<`, `==`
36
+ - `==` → `!=`, `>`, `<`
37
+ - `!=` → `==`, `>`, `<`
38
+ - `<=>` → `==`
39
+
40
+ ## Strict Equality Mutations (opt-in)
41
+
42
+ - `==` → `eql?` (value equality without numeric type coercion: `1 == 1.0` is true, `1.eql?(1.0)` is false)
43
+ - `==` → `equal?` (object identity instead of value equality)
44
+ - **Off by default** and generated only when explicitly enabled via `config.mutation_types[:strict_equality] = true` or
45
+ the CLI flag `--strict-equality`. The default mutation set generates none of these probes, preserving its
46
+ zero-equivalent-mutant property.
47
+ - Purpose: code that is sensitive to numeric types or object identity (money amounts, identifiers, cache keys). A
48
+ surviving `eql?` probe means no test distinguishes Integer from Float inputs (e.g. no `divide(10.0, 0.0)` case); a
49
+ surviving `equal?` probe means no test distinguishes equal-value objects from the same object.
50
+ - **Warning: expect noise.** On code that intentionally does not distinguish numeric types or identity, these probes
51
+ survive as equivalent-in-practice mutants even against a complete test suite, lowering the score without pointing at a
52
+ real gap. Enable the mode deliberately for type-sensitive code, not in default CI runs; use the
53
+ `# mutation_tester:disable` line annotation for individual false positives.
54
+ - Applies to `==` sends with an explicit receiver and one operand. Reported with `type: strict_equality` (e.g.
55
+ `Change == to eql?`).
56
+
57
+ ## Logical Mutations
58
+
59
+ - `&&` → `||`
60
+ - `||` → `&&`
61
+ - `||=` → `&&=`
62
+ - `&&=` → `||=`
63
+ - Operand removal: each `a && b` / `a || b` (and the keyword forms `a and b` / `a or b`) also yields two mutants
64
+ replacing the whole expression with just `a` and with just `b`. A flag whose branch the suite never exercises (say
65
+ `vip || total >= limit` with no VIP test) survives as an operand-removal mutant even when the `||` → `&&` swap is
66
+ killed.
67
+ - Chains like `a || b || c` are mutated per logical node, removing each operand separately (`a || b`, `c`, `a || c`,
68
+ `b || c`).
69
+ - Reported with `type: logical`; the description names the removed operand (e.g. `Remove operand @vip from ||`). A
70
+ removal candidate whose code would no longer parse is dropped at generation time.
71
+
72
+ ## Boolean Mutations
73
+
74
+ - `true` → `false`
75
+ - `false` → `true`
76
+
77
+ ## Number Mutations
78
+
79
+ - Any number → `0`, `1`, `number + 1`, `number - 1`
80
+
81
+ ## String Mutations
82
+
83
+ - Any string → empty string `""`
84
+
85
+ ## Conditional Mutations
86
+
87
+ - Negate conditions in `if` statements (also `unless`, ternary `?:`, and `elsif` branches)
88
+ - Negate the condition of `while` and `until` loops, including the modifier forms (`x while cond`, `x until cond`) and
89
+ post forms (`begin ... end while cond`)
90
+ - The whole condition is wrapped as `!(cond)`. A negated loop condition may never terminate; that infinite-loop mutant
91
+ is caught by the per-mutant timeout and counted as killed.
92
+ - `case`/`when` branch deletion: for each `when` clause of a `case`, one mutant removes that clause so its values fall
93
+ through to the next `when`/`else` (or return `nil`). A `case` with a single `when` is left alone (removing it would
94
+ not parse), and Ruby `case/in` pattern matching is not mutated. Literals inside a `when` (numbers, strings, range
95
+ bounds) are still mutated by their own types.
96
+
97
+ ## Call Removal Mutations
98
+
99
+ - Removes a pure transforming call from a chain, replacing `recv.m` with `recv` (e.g. `items.uniq.sum` → `items.sum`). A
100
+ test suite that never exercises the transformation (say, no input with duplicates for `uniq`) lets this mutant
101
+ survive.
102
+ - Applies only to a curated whitelist of pure, no-argument transformations: `uniq`, `compact`, `sort`, `flatten`,
103
+ `strip`, `chomp`, `downcase`, `upcase`, `capitalize`, `reverse`, `round`, `floor`, `ceil`, `abs`, `to_a`. `freeze` and
104
+ `dup` are deliberately excluded: removing them is behaviorally equivalent under normal test observation (e.g. dropping
105
+ `.freeze` on a constant is unkillable black-box), and the default set keeps the zero-equivalent-mutants property.
106
+ - Calls with arguments or a block, calls without an explicit receiver, and safe-navigation calls (`recv&.m`) are never
107
+ removed. A removal candidate whose code would no longer parse is dropped at generation time.
108
+ - Reported with `type: call_removal`; `original` is the removed call expression (e.g. `items.uniq`) and `mutated` is the
109
+ bare receiver (e.g. `items`).
110
+
111
+ ## Nil Injection Mutations
112
+
113
+ - Replaces the last expression of a method body (`def` and `def self.`) with `nil`, unless it is already a literal`nil`.
114
+ A single-expression method has its whole body replaced. The most common catch is a fluent API returning a final`self`:
115
+ a spec that only checks side effects lets the `self` → `nil` mutant survive.
116
+ - Replaces the right-hand side of an instance variable assignment (`@x = expr` → `@x = nil`), unless the assigned value
117
+ is already `nil`. Local variable assignments and `||=`/`&&=` memoization are intentionally left alone.
118
+ - Limitation: only the last expression of the method body is mutated. Expressions returned via an explicit `return` in
119
+ the middle of the method are not mutated in this iteration, and methods whose body ends in a `rescue`/`ensure` clause
120
+ are skipped.
121
+ - A candidate whose code would no longer parse is dropped at generation time.
122
+ - Reported with `type: nil_injection`; `original` is the replaced expression (e.g. `self`) and `mutated` is `nil`.
123
+
124
+ ## Argument Mutations
125
+
126
+ - Removes the last argument of a regular method call: `m(a, b)` → `m(a)`, `m(a)` → `m()`, and the paren-free style is
127
+ preserved (`raise ArgumentError, msg` → `raise ArgumentError`, `raise msg` → `raise`). Only the last argument is
128
+ removed per mutant; a two-argument `raise` never degenerates to a bare `raise` in a single mutant.
129
+ - Substitutes `nil` for each argument separately (`Result.new(:ok, format_label(total))` → `Result.new(:ok, nil)`),
130
+ unless the argument is already the `nil` literal. A test that never verifies the effect of an argument (say, an
131
+ exception message asserted only by class, or a constructor field no spec reads) lets these mutants survive even when
132
+ the argument is not a literal.
133
+ - Exclusions: operator sends (`+`, `==`, `[]`, `[]=`, `<<`, setters, ...), require-like calls (`require`,
134
+ `require_relative`, `load`, `autoload`), block-pass arguments (`&blk`), splats (`*args`), double splats (`**opts`),
135
+ and safe-navigation calls are not mutated by this family. A candidate whose code would no longer parse is dropped at
136
+ generation time.
137
+ - Mutates parameter defaults in method definitions structurally, for any default expression (not only literals): removes
138
+ the default so the parameter becomes required (`def m(x, opts = {})` → `def m(x, opts)`, `def m(vip: false)` →
139
+ `def m(vip:)`) and replaces the default with `nil` (`opts = nil`), unless the default is already the `nil` literal. A
140
+ literal `false` default gets only the removal variant, never the `nil` variant: `false` and `nil` are
141
+ indistinguishable in boolean context, so `vip: nil` would survive as an equivalent mutant against any truth-testing
142
+ code. A surviving removal mutant means no test ever calls the method without that argument.
143
+ - Default-mutation exclusions: parameter names, splat/double-splat and block parameters are untouched. When an optional
144
+ positional parameter precedes a required one (`def m(a = 1, b)`), removing the default would silently change argument
145
+ binding, so only the `nil` variant is generated for those positional parameters; keyword defaults are unaffected by
146
+ ordering.
147
+ - Reported with `type: argument`; `original` is the full original call (e.g. `raise ArgumentError, msg`) and `mutated`is
148
+ the call after the mutation (e.g. `raise ArgumentError`). For a default-value mutant, `original` is the original
149
+ parameter (e.g. `opts = {}`) and `mutated` is the parameter after the mutation (e.g. `opts` or `opts = nil`).
150
+
151
+ ## Equivalent mutants
152
+
153
+ A mutation score of 100% is not always achievable, and a surviving mutation is
154
+ not always a gap in your tests. Some mutations produce code that behaves
155
+ **identically** to the original for every possible input. These are called
156
+ *equivalent mutants*, and no test can ever kill them because there is no input
157
+ that makes them behave differently.
158
+
159
+ For example, in a `max` implementation:
160
+
161
+ ```ruby
162
+ a > b ? a : b # original
163
+ a >= b ? a : b # mutant
164
+ ```
165
+
166
+ The two expressions only differ when `a == b`, and in that case both return the
167
+ same value (`a` equals `b`), so the observable result is the same for every
168
+ input. This mutant survives no matter how thorough your tests are.
169
+
170
+ Because equivalent mutants cannot be detected automatically in the general case
171
+ (it is an undecidable problem), treat surviving mutations as *candidates* to
172
+ review rather than guaranteed test gaps. When you determine a survivor is
173
+ equivalent, it is reasonable to accept a mutation score below 100%.
174
+
175
+ ### Excluding a line with `# mutation_tester:disable`
176
+
177
+ Once you have confirmed that a survivor is equivalent, annotate its line so the
178
+ mutator stops generating mutants there. Add a trailing `# mutation_tester:disable`
179
+ comment (the same style as `# rubocop:disable`) to the line you want to skip:
180
+
181
+ ```ruby
182
+ def max(a, b)
183
+ a > b ? a : b # mutation_tester:disable
184
+ end
185
+ ```
186
+
187
+ The mutator reads this marker from the source's comments and skips every mutation
188
+ whose location is on that line, across all mutation types (operator, number,
189
+ string, boolean, conditional, logical, call removal). Excluded lines never enter the mutation
190
+ score or the list of survived mutations, so an accepted equivalent mutant stops
191
+ deflating the score and cluttering the report on every run. The console summary
192
+ reports how many lines were excluded as a separate informational category:
193
+
194
+ ```
195
+ Excluded: 1 line(s) (mutation_tester:disable) 🚫
196
+ ```
197
+
198
+ Notes and current limits:
199
+
200
+ - The annotation is honoured only inside a real comment, never inside a string
201
+ literal.
202
+ - Exclusion is per line: the marker skips only the line it sits on. Placing it on
203
+ its own line (above the code) is a no-op, because that line has nothing to
204
+ mutate.
205
+ - Block ranges (`disable`/`enable`), per-type exclusion
206
+ (`# mutation_tester:disable comparison`), and a global exclusion list in
207
+ configuration are not supported yet.
@@ -0,0 +1,35 @@
1
+ class Calculator
2
+ def add(a, b)
3
+ a + b
4
+ end
5
+
6
+ def subtract(a, b)
7
+ a - b
8
+ end
9
+
10
+ def multiply(a, b)
11
+ a * b
12
+ end
13
+
14
+ def divide(a, b)
15
+ return 0 if b == 0
16
+
17
+ a / b
18
+ end
19
+
20
+ def is_positive?(number)
21
+ number > 0
22
+ end
23
+
24
+ def is_even?(number)
25
+ number % 2 == 0
26
+ end
27
+
28
+ def max(a, b)
29
+ a > b ? a : b
30
+ end
31
+
32
+ def absolute(number)
33
+ number < 0 ? -number : number
34
+ end
35
+ end
@@ -0,0 +1,121 @@
1
+ require_relative 'calculator'
2
+
3
+ RSpec.describe Calculator do
4
+ subject(:calculator) { described_class.new }
5
+
6
+ describe '#add' do
7
+ it 'adds two numbers' do
8
+ expect(calculator.add(2, 3)).to eq(5)
9
+ end
10
+
11
+ it 'adds negative numbers' do
12
+ expect(calculator.add(-2, -3)).to eq(-5)
13
+ end
14
+ end
15
+
16
+ describe '#subtract' do
17
+ it 'subtracts two numbers' do
18
+ expect(calculator.subtract(5, 3)).to eq(2)
19
+ end
20
+
21
+ it 'handles negative results' do
22
+ expect(calculator.subtract(3, 5)).to eq(-2)
23
+ end
24
+ end
25
+
26
+ describe '#multiply' do
27
+ it 'multiplies two numbers' do
28
+ expect(calculator.multiply(3, 4)).to eq(12)
29
+ end
30
+
31
+ it 'returns zero when multiplying by zero' do
32
+ expect(calculator.multiply(5, 0)).to eq(0)
33
+ end
34
+ end
35
+
36
+ describe '#divide' do
37
+ context 'when divisor is zero' do
38
+ it 'returns 0' do
39
+ expect(calculator.divide(10, 0)).to eq(0)
40
+ end
41
+ end
42
+
43
+ context 'when divisor is non-zero' do
44
+ it 'divides two numbers' do
45
+ expect(calculator.divide(10, 2)).to eq(5)
46
+ end
47
+
48
+ it 'performs integer division for integers' do
49
+ expect(calculator.divide(7, 2)).to eq(3)
50
+ end
51
+ end
52
+ end
53
+
54
+ describe '#is_positive?' do
55
+ it 'returns true for positive integers' do
56
+ expect(calculator.is_positive?(5)).to be true
57
+ expect(calculator.is_positive?(1)).to be true
58
+ end
59
+
60
+ it 'returns true for fractional positives' do
61
+ expect(calculator.is_positive?(0.5)).to be true
62
+ end
63
+
64
+ it 'returns false for zero and negatives' do
65
+ expect(calculator.is_positive?(0)).to be false
66
+ expect(calculator.is_positive?(-0.1)).to be false
67
+ expect(calculator.is_positive?(-1)).to be false
68
+ end
69
+ end
70
+
71
+ describe '#is_even?' do
72
+ it 'returns true for even numbers and zero' do
73
+ expect(calculator.is_even?(4)).to be true
74
+ expect(calculator.is_even?(0)).to be true
75
+ end
76
+
77
+ it 'returns false for odd numbers' do
78
+ expect(calculator.is_even?(3)).to be false
79
+ end
80
+ end
81
+
82
+ describe '#max' do
83
+ it 'returns the larger number' do
84
+ expect(calculator.max(5, 3)).to eq(5)
85
+ expect(calculator.max(3, 5)).to eq(5)
86
+ end
87
+
88
+ it 'returns the second object when values are equal' do
89
+ a = String.new('same')
90
+ b = String.new('same')
91
+ expect(calculator.max(a, b)).to equal(b)
92
+ end
93
+ end
94
+
95
+ describe '#absolute' do
96
+ it 'returns positive number unchanged' do
97
+ expect(calculator.absolute(5)).to eq(5)
98
+ end
99
+
100
+ it 'returns absolute value for negative numbers' do
101
+ expect(calculator.absolute(-5)).to eq(5)
102
+ expect(calculator.absolute(-1)).to eq(1)
103
+ end
104
+
105
+ it 'distinguishes negative zero and positive zero for floats' do
106
+ neg_zero = calculator.absolute(-0.0)
107
+ pos_zero = calculator.absolute(0.0)
108
+
109
+ expect(1.0 / neg_zero).to eq(-Float::INFINITY)
110
+ expect(1.0 / pos_zero).to eq(Float::INFINITY)
111
+ end
112
+
113
+ it 'returns zero for integer zero' do
114
+ expect(calculator.absolute(0)).to eq(0)
115
+ end
116
+
117
+ it 'handles fractional positives' do
118
+ expect(calculator.absolute(0.5)).to eq(0.5)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,53 @@
1
+ require 'minitest/autorun'
2
+ require_relative 'calculator'
3
+
4
+ class CalculatorTest < Minitest::Test
5
+ def setup
6
+ @calculator = Calculator.new
7
+ end
8
+
9
+ def test_add
10
+ assert_equal 5, @calculator.add(2, 3)
11
+ assert_equal(-5, @calculator.add(-2, -3))
12
+ end
13
+
14
+ def test_subtract
15
+ assert_equal 2, @calculator.subtract(5, 3)
16
+ assert_equal(-2, @calculator.subtract(3, 5))
17
+ end
18
+
19
+ def test_multiply
20
+ assert_equal 12, @calculator.multiply(3, 4)
21
+ assert_equal 0, @calculator.multiply(5, 0)
22
+ end
23
+
24
+ def test_divide
25
+ assert_equal 5, @calculator.divide(10, 2)
26
+ assert_equal 0, @calculator.divide(10, 0)
27
+ assert_equal 3, @calculator.divide(7, 2)
28
+ end
29
+
30
+ def test_is_positive
31
+ assert @calculator.is_positive?(5)
32
+ refute(@calculator.is_positive?(-5))
33
+ refute(@calculator.is_positive?(0))
34
+ end
35
+
36
+ def test_is_even
37
+ assert @calculator.is_even?(4)
38
+ refute(@calculator.is_even?(3))
39
+ assert @calculator.is_even?(0)
40
+ end
41
+
42
+ def test_max
43
+ assert_equal 5, @calculator.max(5, 3)
44
+ assert_equal 5, @calculator.max(3, 5)
45
+ assert_equal 5, @calculator.max(5, 5)
46
+ end
47
+
48
+ def test_absolute
49
+ assert_equal 5, @calculator.absolute(5)
50
+ assert_equal 5, @calculator.absolute(-5)
51
+ assert_equal 0, @calculator.absolute(0)
52
+ end
53
+ end