mutineer 1.2.0 → 1.4.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 +4 -4
- data/CHANGELOG.md +133 -0
- data/README.md +53 -4
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/changed_lines.rb +34 -2
- data/lib/mutineer/cli.rb +102 -81
- data/lib/mutineer/config.rb +157 -49
- data/lib/mutineer/coverage_map.rb +7 -30
- data/lib/mutineer/daemon_backend.rb +5 -4
- data/lib/mutineer/isolation.rb +8 -3
- data/lib/mutineer/mutant_id.rb +72 -13
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/condition_forcing.rb +141 -0
- data/lib/mutineer/mutators/operator_assignment.rb +120 -0
- data/lib/mutineer/project.rb +47 -18
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/reporter.rb +18 -5
- data/lib/mutineer/result.rb +2 -2
- data/lib/mutineer/runner.rb +64 -20
- data/lib/mutineer/subject.rb +13 -2
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e18649829a9226ea7a5f654ef38602eabff4005ebf8a516fca4402460a7aac09
|
|
4
|
+
data.tar.gz: 9e3c166e5e2e513278aaf210f271ed0f070cd335d0de588318cb8aa71f6b6a06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e10e1214d0b55a4c93c7fa13d4c3b39503d4e2b1e367a995e2d813e98bd55ce2c2774b429bd690f2ae55019a39c7ee539e3ef60ea51bf25e2bc61c602ab72b6
|
|
7
|
+
data.tar.gz: 146768f9f599d41af9e6549f65ad7658cdd55e3d57a6f35fa6d094cfef6be73b036bb3dc8ebe8616d03cf358cdaf78e5d8a3b40a996c7a4d18a06898b9454824
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,137 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.4.0] - 2026-09-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Condition-forcing operators** (Tier-2, opt-in via `--operators`):
|
|
13
|
+
`condition_true` and `condition_false` replace an `if`/`elsif`/`unless`,
|
|
14
|
+
ternary, modifier or `case`/`in` guard condition with `(true)` or `(false)`, so
|
|
15
|
+
its branch always runs or never runs. A surviving mutant means no selected
|
|
16
|
+
test detected the forced condition. A literal condition, also in
|
|
17
|
+
parentheses, makes no mutant: forcing it changes nothing or repeats the
|
|
18
|
+
`boolean_literal` flip. A condition that holds a heredoc makes no mutant.
|
|
19
|
+
A condition that assigns a variable keeps its code, and only its value
|
|
20
|
+
is forced (`(m = x; true)`), so later code still sees the variable.
|
|
21
|
+
The never-runs side of an else-less conditional can
|
|
22
|
+
be the same program as the `nil` that `statement_removal` or `return_nil`
|
|
23
|
+
puts in its place; it is still made, so these mutants and their ids do not
|
|
24
|
+
depend on which other operators run.
|
|
25
|
+
- **Operator-assignment operator** (Tier-2, opt-in via `--operators`):
|
|
26
|
+
`operator_assignment` swaps the operator of a compound assignment:
|
|
27
|
+
`+=` <-> `-=`, `*=` <-> `/=`, and `%=` and `**=` -> `*=`. It covers local,
|
|
28
|
+
instance, class and global variables, constants, calls (`a.b += 1`) and
|
|
29
|
+
index calls (`a[i] += 1`). The `arithmetic` operator never sees these
|
|
30
|
+
forms, because Prism does not parse them as calls. The operator does not
|
|
31
|
+
change `||=`, `&&=`, or the bitwise and shift forms (`|=`, `<<=`).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **A typed `--rails`, `--verbose` or `--debug` beats `rails: false` and
|
|
35
|
+
`verbose: false` in `.mutineer.yml`** (#103). The flag was dropped, so the
|
|
36
|
+
run went on without Rails boot or verbose output. The config layers now keep
|
|
37
|
+
only the keys the user wrote, and "did the user write this" comes from those
|
|
38
|
+
keys, not from a hand-kept list.
|
|
39
|
+
- **`framework: rspec` in `.mutineer.yml` survives test pairing** (#103).
|
|
40
|
+
Pairing re-detected the framework from the test file names, so an RSpec
|
|
41
|
+
suite in `test/calc_test.rb` ran under Minitest.
|
|
42
|
+
- **A bad number is an error, not a rounded value** (#105). `--jobs 1.9` and
|
|
43
|
+
`--baseline-epsilon abc` no longer become `1` and `0.0`. One option schema
|
|
44
|
+
parses each value once, where it enters, for both the command line and
|
|
45
|
+
`.mutineer.yml`.
|
|
46
|
+
- **`--since` keeps a source file that git does not track yet** (#156). Before,
|
|
47
|
+
such a file counted as unchanged. The run scored no mutants, and a positive
|
|
48
|
+
`--threshold` still exited 0. The file is now new in full, so all its
|
|
49
|
+
mutants run.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- **These now exit 2 with a message that names the option**: `jobs: 1.9` or
|
|
53
|
+
`jobs: true` in `.mutineer.yml` (before: `1`, or a crash); a bad, negative
|
|
54
|
+
or non-finite `--baseline-epsilon` (before: `0.0`); a boolean key in
|
|
55
|
+
`.mutineer.yml` that is not `true` or `false`, such as the string `"yes"`
|
|
56
|
+
(before: `false`).
|
|
57
|
+
- **`--jobs` takes plain digits only.** `0x2`, `+2`, `" 2"` and `1_0` exit 2.
|
|
58
|
+
Before, `Integer()` read them as 2, 2, 2 and 10. `--threshold` and
|
|
59
|
+
`--baseline-epsilon` take a plain decimal, such as `2` or `2.5`, from a
|
|
60
|
+
string. `0x10`, `+2`, `1_0` and `1e2` exit 2. Before, `Float()` read them
|
|
61
|
+
as 16.0, 2.0, 10.0 and 100.0.
|
|
62
|
+
- **A string option that gets `true`, `false` or no value exits 2**, such as
|
|
63
|
+
`only: false` or `baseline:` in `.mutineer.yml`. A blank `since` also exits 2,
|
|
64
|
+
in the file and on the command line, so an unset shell variable does not
|
|
65
|
+
turn scoping off. `since: false` still means no scoping.
|
|
66
|
+
- **Config file errors start with the file and key**, for example
|
|
67
|
+
`.mutineer.yml: threshold must be a number between 0 and 100`, where they
|
|
68
|
+
started with `--threshold`.
|
|
69
|
+
- **The docs site is built in CI** — a Pages workflow runs `rake site:build`
|
|
70
|
+
and deploys the result, so the YARD HTML under `/api/`, `llms-full.txt`,
|
|
71
|
+
`json-schema.html` and `sitemap.xml` are no longer committed. CI checks
|
|
72
|
+
that the site builds, in place of `rake yard:pages:check`. The site keeps
|
|
73
|
+
its URLs (#153).
|
|
74
|
+
|
|
75
|
+
## [1.3.0] - 2026-09-29
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
- **Mutant ids now include the project-relative file path** (#126). Before, two
|
|
79
|
+
mutants with the same method name, operator and token in different files got
|
|
80
|
+
the same id. That happened with a top-level `def` or block in two files, and
|
|
81
|
+
with a class reopened in another file. One `ignore:` entry then suppressed
|
|
82
|
+
both mutants, and `--baseline` could hide a new survivor behind an old one.
|
|
83
|
+
- Every survivor id changes once in this release. This affects any external
|
|
84
|
+
tool that tracks survivors by id.
|
|
85
|
+
- Moving or renaming a file now changes its ids.
|
|
86
|
+
- Ids follow the project root: the directory mutineer runs from, or the
|
|
87
|
+
Action's `working-directory`. A run from a different root gives different ids.
|
|
88
|
+
mutineer finds `.mutineer.yml` by walking up, so a run from a subdirectory
|
|
89
|
+
prints one `[mutineer]` warning that the loaded ignore ids will not match.
|
|
90
|
+
- A source outside the project root uses its absolute path, so its ids differ
|
|
91
|
+
between machines.
|
|
92
|
+
- Two methods with the same qualified name in one file (for example two
|
|
93
|
+
top-level `def index` in two DSL blocks) now get different ids. The second
|
|
94
|
+
and later ones hash their position among those methods; the first keeps its
|
|
95
|
+
id.
|
|
96
|
+
- **The JSON report marks its id format** (schema `1.4`, additive).
|
|
97
|
+
`summary.id_format` is `2` for ids that include the file path.
|
|
98
|
+
`summary.legacy_id_matches` counts old-format `ignore:` entries (`ignore`) and
|
|
99
|
+
survivors matched only through an old-format baseline id (`baseline`). The
|
|
100
|
+
GitHub Action shows one warning annotation when either count is not zero.
|
|
101
|
+
|
|
102
|
+
### Deprecated
|
|
103
|
+
- **Old-format ids in `ignore:` and in baselines.** Matching on them is removed
|
|
104
|
+
in 2.0. Replace each old `ignore:` entry with the new ids from the warning
|
|
105
|
+
(only the intended ones when it over-matched several mutants).
|
|
106
|
+
Regenerate a baseline (`--format json`) only after every gate that reads it
|
|
107
|
+
runs this version or later (the Action's `version:` pin, your CI
|
|
108
|
+
`Gemfile.lock`). An older version treats every new-format survivor as new.
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
- **`module_function :name` and `module_function def name` promote only
|
|
112
|
+
their own module's methods** — a class or module in the same file with a
|
|
113
|
+
method of the same name kept an instance method in Ruby, but mutineer
|
|
114
|
+
named it as a class method. `--strategy redefine` then mutated a method the
|
|
115
|
+
tests never call, so a killable mutant falsely survived, and
|
|
116
|
+
`--only Class#name` selected nothing (#98). The affected methods now get
|
|
117
|
+
their correct names, so their mutant ids change: regenerate any ignore
|
|
118
|
+
entries or baseline survivors that pointed at them.
|
|
119
|
+
- **A root-anchored reopening names the top-level constant** — a
|
|
120
|
+
`module ::Root` or `class ::Solo` written inside another module now gives
|
|
121
|
+
`Root` and `Solo`, not `Outer::Root` and `Outer::Solo`. This applies to every
|
|
122
|
+
method in such a body, with or without `module_function`, so their mutant
|
|
123
|
+
ids change too. `--strategy redefine` rebuilds such a body's scope as
|
|
124
|
+
written (`module Outer` then `class ::Solo`), so a constant from `Outer`
|
|
125
|
+
still resolves in the mutated method, as it does under `reload`. Before,
|
|
126
|
+
the method raised NameError in the test, which counted as a false kill
|
|
127
|
+
(#145).
|
|
128
|
+
- **Old-format ids keep working, with a warning** (#126). An old-format
|
|
129
|
+
`ignore:` entry still suppresses the mutants it matched before. The run prints
|
|
130
|
+
one `[mutineer]` warning per entry with each new id, its file and its method.
|
|
131
|
+
When the entry matched several mutants (in different files, or same-named
|
|
132
|
+
methods in one file), the warning says it
|
|
133
|
+
over-matched and to keep only the ids for the mutant you meant to ignore. A
|
|
134
|
+
`--baseline` file without `summary.id_format` matches on new ids, or on old
|
|
135
|
+
ids from the same file, so no survivor reads as new or fixed only because its
|
|
136
|
+
id changed. A stored file outside the project root (a baseline written on
|
|
137
|
+
another machine) matches on the old id alone. The run prints one
|
|
138
|
+
`[mutineer]` warning to regenerate the baseline.
|
|
139
|
+
|
|
9
140
|
## [1.2.0] - 2026-09-28
|
|
10
141
|
|
|
11
142
|
### Added
|
|
@@ -546,6 +677,8 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
546
677
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
547
678
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
548
679
|
|
|
680
|
+
[1.4.0]: https://github.com/davidteren/mutineer/releases/tag/v1.4.0
|
|
681
|
+
[1.3.0]: https://github.com/davidteren/mutineer/releases/tag/v1.3.0
|
|
549
682
|
[1.2.0]: https://github.com/davidteren/mutineer/releases/tag/v1.2.0
|
|
550
683
|
[1.1.0]: https://github.com/davidteren/mutineer/releases/tag/v1.1.0
|
|
551
684
|
[1.0.2]: https://github.com/davidteren/mutineer/releases/tag/v1.0.2
|
data/README.md
CHANGED
|
@@ -85,7 +85,8 @@ Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
|
|
|
85
85
|
Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
|
|
86
86
|
`literal_mutation`, `condition_negation`, `string_literal`, `regex`,
|
|
87
87
|
`collection_method`, `safe_navigation`, `range`, `negation_removal`, `chain_link`,
|
|
88
|
-
`operand_removal`, `array_literal
|
|
88
|
+
`operand_removal`, `array_literal`, `condition_true`,
|
|
89
|
+
`condition_false`, `operator_assignment`.
|
|
89
90
|
|
|
90
91
|
## Rails apps
|
|
91
92
|
|
|
@@ -214,11 +215,59 @@ Some mutants are equivalent (behaviour-identical) and survive forever — keepin
|
|
|
214
215
|
file off 100%. Suppress them so the score and `--threshold` gate stay meaningful:
|
|
215
216
|
|
|
216
217
|
- **Inline:** `some_line # mutineer:disable-line` (or scope it: `# mutineer:disable-line comparison`). Put a reason after `--`: `# mutineer:disable-line comparison -- the test checks only 20`.
|
|
217
|
-
- **Config:** a `.mutineer.yml` `ignore:` list of
|
|
218
|
+
- **Config:** a `.mutineer.yml` `ignore:` list of mutant ids. Each survivor's
|
|
218
219
|
`id` is printed in the JSON report, so copy it straight into `ignore:`.
|
|
219
220
|
|
|
220
221
|
Suppressed mutants are excluded from the score (so 100% becomes reachable).
|
|
221
222
|
|
|
223
|
+
## Mutant ids
|
|
224
|
+
|
|
225
|
+
A mutant id is 12 hex characters. It hashes the file path (relative to the
|
|
226
|
+
project root), the method's qualified name, the operator, the mutated code, and
|
|
227
|
+
the mutant's position among identical mutants in that method. When one file has
|
|
228
|
+
two methods with the same qualified name (for example two top-level `def index`
|
|
229
|
+
in two DSL blocks), the second and later ones also hash their position among
|
|
230
|
+
those methods, so their ids differ. The first one's id does not change. An edit
|
|
231
|
+
outside the method does not change the id. Moving or renaming the file,
|
|
232
|
+
renaming the method or its class, or adding an identical mutant earlier in the
|
|
233
|
+
method does. Adding a method with the same name earlier in the same file also
|
|
234
|
+
does.
|
|
235
|
+
|
|
236
|
+
- The project root is the directory mutineer runs from (in the Action, the
|
|
237
|
+
`working-directory`). Run from the same root to get the same ids.
|
|
238
|
+
- A source outside the project root uses its absolute path, so its ids differ
|
|
239
|
+
between machines.
|
|
240
|
+
|
|
241
|
+
**Migrating from ids without the file path.** Before 1.3, ids did not include
|
|
242
|
+
the file path, so two files could share an id (#126). Old-format ids keep
|
|
243
|
+
working until 2.0, with a warning:
|
|
244
|
+
|
|
245
|
+
- **`ignore:`** An old entry still suppresses its mutants. The run prints the
|
|
246
|
+
new ids for each old entry, each with its file and method. When it names one
|
|
247
|
+
mutant, replace the entry with that id. When it names several (in different
|
|
248
|
+
files, or same-named methods in one file), the old entry over-matched: it also
|
|
249
|
+
hid mutants you did not mean to ignore. The warning says so. Keep only the ids for the mutant you meant to
|
|
250
|
+
ignore, not all of them. The list covers only the sources and operators in
|
|
251
|
+
that run, so run over every source with every operator set you use (for
|
|
252
|
+
example your Tier-2 `--operators`) for the full list.
|
|
253
|
+
- **`--baseline`** An old baseline still matches: a survivor matches a stored
|
|
254
|
+
one with the same old id in the same file. A stored file that is an absolute
|
|
255
|
+
path outside the project root (a baseline written on another machine)
|
|
256
|
+
matches on the old id alone. The run tells you to
|
|
257
|
+
regenerate it. Regenerate it with `--format json`, but only after every gate
|
|
258
|
+
that reads it runs 1.3 or later (the Action's `version:` pin, your CI
|
|
259
|
+
`Gemfile.lock`). An older version treats every new-format survivor as new.
|
|
260
|
+
|
|
261
|
+
The JSON report's `summary.id_format` is `2` for the new format.
|
|
262
|
+
`summary.legacy_id_matches.ignore` counts the old-format ignore entries a run
|
|
263
|
+
matched, and `summary.legacy_id_matches.baseline` counts the survivors matched
|
|
264
|
+
only through an old baseline id.
|
|
265
|
+
|
|
266
|
+
Ids are relative to the directory you run mutineer from. mutineer finds
|
|
267
|
+
`.mutineer.yml` by walking up. When the file it loads is in a parent directory
|
|
268
|
+
(other than your home directory), it warns that the ignore ids will not match
|
|
269
|
+
and tells you which directory to run from.
|
|
270
|
+
|
|
222
271
|
## CI gating
|
|
223
272
|
|
|
224
273
|
Store a JSON run as a baseline, then fail the build only when a PR makes things
|
|
@@ -228,7 +277,7 @@ worse:
|
|
|
228
277
|
mutineer run app/ --baseline .mutineer/baseline.json # exit 1 on NEW survivors or a score drop
|
|
229
278
|
```
|
|
230
279
|
|
|
231
|
-
`--baseline` reports which survivors are new (by
|
|
280
|
+
`--baseline` reports which survivors are new (by [mutant id](#mutant-ids)) and any score drop. It
|
|
232
281
|
combines with `--threshold` (the worse of the two sets the exit code). Pass a
|
|
233
282
|
directory (or several sources) to audit a whole layer in one boot — tests are
|
|
234
283
|
auto-paired by convention and the report breaks down per source.
|
|
@@ -267,7 +316,7 @@ the format.
|
|
|
267
316
|
|
|
268
317
|
## For AI agents & pipelines
|
|
269
318
|
|
|
270
|
-
Mutineer is built for programmatic use — versioned JSON,
|
|
319
|
+
Mutineer is built for programmatic use — versioned JSON, [mutant ids](#mutant-ids) that survive unrelated edits,
|
|
271
320
|
structured exit codes, and diff-scoped runs. See:
|
|
272
321
|
|
|
273
322
|
- **AI agents & CI recipes** — the agent inner-loop and CI-gate recipes (and how
|
data/lib/mutineer/baseline.rb
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "set"
|
|
4
5
|
require_relative "config" # for Mutineer::ConfigError
|
|
6
|
+
require_relative "project_path"
|
|
5
7
|
|
|
6
8
|
module Mutineer
|
|
7
9
|
# CI baseline/delta gating. A baseline is a prior
|
|
8
10
|
# `mutineer run --format json` document (no bespoke format to version).
|
|
9
|
-
# Diff the current run against it by
|
|
11
|
+
# Diff the current run against it by survivor id: a NEW survivor
|
|
10
12
|
# (id present now, absent in the baseline) OR a score drop is a regression the
|
|
11
13
|
# CLI turns into exit 1. Pure data, stdlib `json` only, no fork, no Rails, so
|
|
12
14
|
# it is testable in isolation from a canned JSON + a hand-built AggregateResult.
|
|
13
15
|
class Baseline
|
|
14
16
|
# The verdict of diffing a current run against the baseline.
|
|
15
|
-
# new_survivors - current Result objects whose
|
|
17
|
+
# new_survivors - current Result objects whose id is absent from
|
|
16
18
|
# the baseline (the regressions to name).
|
|
17
19
|
# fixed_survivors - baseline survivor hashes absent from the current run
|
|
18
20
|
# (informational, never gates). Empty when either side
|
|
@@ -26,8 +28,18 @@ module Mutineer
|
|
|
26
28
|
# render them side by side. False means the score-drop
|
|
27
29
|
# check was skipped, not that it passed.
|
|
28
30
|
# regressed - any new survivors OR a score drop.
|
|
31
|
+
# legacy_matches - current survivors found in an old-format baseline
|
|
32
|
+
# (no `summary.id_format`) only through their old-format
|
|
33
|
+
# id (#126). Non-zero means the baseline should be
|
|
34
|
+
# regenerated; the CLI warns. Always 0 for a new-format
|
|
35
|
+
# baseline.
|
|
29
36
|
Delta = Data.define(:new_survivors, :fixed_survivors,
|
|
30
|
-
:score_before, :score_after, :score_drop, :score_comparable, :regressed
|
|
37
|
+
:score_before, :score_after, :score_drop, :score_comparable, :regressed,
|
|
38
|
+
:legacy_matches) do
|
|
39
|
+
# @param legacy_matches [Integer] survivors matched only through an old-format id.
|
|
40
|
+
# @return [void]
|
|
41
|
+
def initialize(legacy_matches: 0, **) = super
|
|
42
|
+
end
|
|
31
43
|
|
|
32
44
|
# Load a prior --format json run. Raises ConfigError (NOT exit: a data class
|
|
33
45
|
# must never kill the host) on a missing/unreadable file, unparseable JSON,
|
|
@@ -73,35 +85,74 @@ module Mutineer
|
|
|
73
85
|
# Strict literal true only: a malformed value (say the STRING "false" in a
|
|
74
86
|
# hand-edited baseline) must not silently disable the score-drop gate.
|
|
75
87
|
@scoped = doc.dig("summary", "scoped") == true
|
|
88
|
+
# nil for a report written before ids included the file path (#126).
|
|
89
|
+
@id_format = doc.dig("summary", "id_format")
|
|
76
90
|
end
|
|
77
91
|
|
|
78
|
-
# Diff a current AggregateResult against this baseline by
|
|
92
|
+
# Diff a current AggregateResult against this baseline by survivor id.
|
|
79
93
|
# `epsilon` tolerates float jitter on the score (default 0.0 = any drop
|
|
80
94
|
# gates).
|
|
81
95
|
#
|
|
82
96
|
# `scoped: true` marks the current run as diff-scoped (`--since`): its score
|
|
83
97
|
# is computed over only the changed-line mutants, a different denominator
|
|
84
98
|
# from a full-run baseline, so comparing the two scores manufactures false
|
|
85
|
-
# regressions. A scoped diff keeps the new-survivor gate (
|
|
99
|
+
# regressions. A scoped diff keeps the new-survivor gate (ids compare
|
|
86
100
|
# fine across scopes) and still reports both scores, but never sets
|
|
87
101
|
# score_drop.
|
|
88
102
|
#
|
|
103
|
+
# `id_map` maps each current new-format id to its old-format id (#126). A
|
|
104
|
+
# baseline without `summary.id_format` stores old-format ids, so a current
|
|
105
|
+
# survivor matches if its new OR old id is stored, and a stored id seen under
|
|
106
|
+
# either form is not fixed. Matches made only through the old id are counted
|
|
107
|
+
# on Delta#legacy_matches. The old id has no file path, so an old-id match
|
|
108
|
+
# must also come from the same file (the stored survivor's `file`, normalized
|
|
109
|
+
# against `project_root`): an equal old id from another file is a different
|
|
110
|
+
# mutant and stays new. A stored `file` that is absolute and outside
|
|
111
|
+
# `project_root` (a baseline written on another machine) can never equal a
|
|
112
|
+
# current file, so that survivor matches on its old id alone, as before #126.
|
|
113
|
+
#
|
|
89
114
|
# @param aggregate [Mutineer::AggregateResult] current results.
|
|
90
115
|
# @param epsilon [Float] score-drop tolerance.
|
|
91
116
|
# @param scoped [Boolean] current run was diff-scoped (`--since`).
|
|
117
|
+
# @param id_map [Hash{String => String}] current new id => old-format id.
|
|
118
|
+
# @param project_root [String] root that survivor `file` paths resolve against.
|
|
92
119
|
# @return [Mutineer::Baseline::Delta] delta summary.
|
|
93
|
-
def diff(aggregate, epsilon: 0.0, scoped: false)
|
|
120
|
+
def diff(aggregate, epsilon: 0.0, scoped: false, id_map: {}, project_root: Dir.pwd)
|
|
94
121
|
current = aggregate.surviving_mutants
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
baseline_ids = @survivors.map { |h| h["id"] }.to_set
|
|
123
|
+
# A new-format baseline never matches on old ids.
|
|
124
|
+
legacy = @id_format.nil? ? id_map : {}
|
|
125
|
+
file_key = ->(path) { path && ProjectPath.relative(path, project_root) }
|
|
126
|
+
# [old id, file] for each stored survivor: an old id alone is ambiguous.
|
|
127
|
+
baseline_pairs = @survivors.map { |h| [h["id"], file_key.call(h["file"])] }.to_set
|
|
128
|
+
# Old ids stored with a file from another machine: matched on the id alone.
|
|
129
|
+
foreign = @survivors.select { |h| foreign_file?(h["file"], file_key) }.to_set
|
|
130
|
+
foreign_ids = foreign.map { |h| h["id"] }.to_set
|
|
131
|
+
legacy_pair = ->(r) { [legacy[r.id], file_key.call(r.subject&.file)] }
|
|
97
132
|
|
|
98
|
-
new_survivors =
|
|
133
|
+
new_survivors = []
|
|
134
|
+
legacy_matches = 0
|
|
135
|
+
current.each do |r|
|
|
136
|
+
next if baseline_ids.include?(r.id)
|
|
137
|
+
|
|
138
|
+
if legacy[r.id] && (baseline_pairs.include?(legacy_pair.call(r)) || foreign_ids.include?(legacy[r.id]))
|
|
139
|
+
legacy_matches += 1
|
|
140
|
+
else
|
|
141
|
+
new_survivors << r
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
current_ids = current.map(&:id).to_set
|
|
145
|
+
current_pairs = current.select { |r| legacy[r.id] }.map { |r| legacy_pair.call(r) }.to_set
|
|
146
|
+
current_legacy_ids = current.filter_map { |r| legacy[r.id] }.to_set
|
|
99
147
|
# Under a diff-scoped side an out-of-scope baseline survivor was never
|
|
100
148
|
# re-tested, so reporting it "fixed" would be false: empty is honest.
|
|
101
149
|
fixed = if scoped || @scoped
|
|
102
150
|
[]
|
|
103
151
|
else
|
|
104
|
-
@survivors.reject
|
|
152
|
+
@survivors.reject do |h|
|
|
153
|
+
current_ids.include?(h["id"]) || current_pairs.include?([h["id"], file_key.call(h["file"])]) ||
|
|
154
|
+
(foreign.include?(h) && current_legacy_ids.include?(h["id"]))
|
|
155
|
+
end
|
|
105
156
|
end
|
|
106
157
|
|
|
107
158
|
current_score = aggregate.mutation_score
|
|
@@ -116,7 +167,19 @@ module Mutineer
|
|
|
116
167
|
Delta.new(new_survivors: new_survivors, fixed_survivors: fixed,
|
|
117
168
|
score_before: @score, score_after: current_score,
|
|
118
169
|
score_drop: score_drop, score_comparable: comparable,
|
|
119
|
-
regressed: !new_survivors.empty? || score_drop)
|
|
170
|
+
regressed: !new_survivors.empty? || score_drop, legacy_matches: legacy_matches)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# True when a stored survivor's `file` is absolute and still absolute after
|
|
176
|
+
# normalizing against the project root, so it lies outside this checkout.
|
|
177
|
+
#
|
|
178
|
+
# @param file [String, nil] the stored survivor's `file`.
|
|
179
|
+
# @param file_key [Proc] normalizes a path against the project root.
|
|
180
|
+
# @return [Boolean] whether the file can never equal a current file.
|
|
181
|
+
def foreign_file?(file, file_key)
|
|
182
|
+
!file.nil? && File.absolute_path?(file) && File.absolute_path?(file_key.call(file))
|
|
120
183
|
end
|
|
121
184
|
end
|
|
122
185
|
end
|
|
@@ -56,6 +56,10 @@ module Mutineer
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# Returns the stdout of `git -C <root> diff --unified=0 <ref> -- <file>`.
|
|
59
|
+
# A file that is not in the index has no usable diff, so `new_file_diff`
|
|
60
|
+
# supplies one that marks every line new. That includes a file the ref still
|
|
61
|
+
# has, deleted and then written again: git prints only a deletion hunk for it.
|
|
62
|
+
# Paths are literal, so a name such as `file[1].rb` is not read as a glob.
|
|
59
63
|
#
|
|
60
64
|
# A failure is warned, never silent: an empty result means "no changed
|
|
61
65
|
# lines", which under `--since` removes every mutant for the file — a green
|
|
@@ -67,9 +71,9 @@ module Mutineer
|
|
|
67
71
|
# @return [String] diff text, or `""` on failure (after a stderr warning).
|
|
68
72
|
def git_diff(ref, abs_file, project_root)
|
|
69
73
|
out, _err, status = Open3.capture3(
|
|
70
|
-
"git", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
|
|
74
|
+
"git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
|
|
71
75
|
)
|
|
72
|
-
return out if status.success?
|
|
76
|
+
return(indexed?(abs_file, project_root) ? out : new_file_diff(abs_file)) if status.success?
|
|
73
77
|
|
|
74
78
|
warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
|
|
75
79
|
""
|
|
@@ -77,5 +81,33 @@ module Mutineer
|
|
|
77
81
|
warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
|
|
78
82
|
""
|
|
79
83
|
end
|
|
84
|
+
|
|
85
|
+
# Tells whether the index holds the file. Only then is git's own diff the
|
|
86
|
+
# truth; the diff text cannot tell, because a mode-only diff must stay empty.
|
|
87
|
+
#
|
|
88
|
+
# @param abs_file [String] absolute path of the file being diffed.
|
|
89
|
+
# @param project_root [String] repository root for `git -C`.
|
|
90
|
+
# @return [Boolean] true when the index holds the file.
|
|
91
|
+
def indexed?(abs_file, project_root)
|
|
92
|
+
_out, _err, known = Open3.capture3(
|
|
93
|
+
"git", "--literal-pathspecs", "-C", project_root, "ls-files", "--error-unmatch", "--", abs_file
|
|
94
|
+
)
|
|
95
|
+
known.success?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns a diff that marks a file as entirely new. It returns `""` when
|
|
99
|
+
# the file is empty or cannot be read (after a warning). A file outside the
|
|
100
|
+
# index has every line new; read as "unchanged", `--since` would score
|
|
101
|
+
# nothing and a positive threshold would still exit 0.
|
|
102
|
+
#
|
|
103
|
+
# @param abs_file [String] absolute path of the file being diffed.
|
|
104
|
+
# @return [String] a one-hunk diff header, or `""`.
|
|
105
|
+
def new_file_diff(abs_file)
|
|
106
|
+
count = File.foreach(abs_file).count
|
|
107
|
+
count.zero? ? "" : "@@ -0,0 +1,#{count} @@\n"
|
|
108
|
+
rescue SystemCallError => e
|
|
109
|
+
warn "[mutineer] cannot read #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
|
|
110
|
+
""
|
|
111
|
+
end
|
|
80
112
|
end
|
|
81
113
|
end
|