mutineer 1.2.0 → 1.3.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 +66 -0
- data/README.md +51 -3
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/cli.rb +71 -4
- 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/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
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7b8a623ac7db818d33df11a7ca32e26a67db7f156061027f018debeb17ced2f
|
|
4
|
+
data.tar.gz: 5957fb6e57cb5a5149822818f773f3e8d3da271bce7bb0cfc4d0a8640764aa40
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1de2a131774b7b587abbc8ca91a57da333688cb2fdb937defa002d661426137decf8b86fbde1a2d176a5525f8cb7d9a8b6a2bebc353960729704b3e8d2473213
|
|
7
|
+
data.tar.gz: '094d062c4c8458fda7061a575681f13022131b2b9078e5731bcbbfa300ac67b86e53691e4d9e104cc06e50832b66bd3dd32433c1bdf420e74b75a7b7748b4916'
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,71 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.3.0] - 2026-09-29
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Mutant ids now include the project-relative file path** (#126). Before, two
|
|
13
|
+
mutants with the same method name, operator and token in different files got
|
|
14
|
+
the same id. That happened with a top-level `def` or block in two files, and
|
|
15
|
+
with a class reopened in another file. One `ignore:` entry then suppressed
|
|
16
|
+
both mutants, and `--baseline` could hide a new survivor behind an old one.
|
|
17
|
+
- Every survivor id changes once in this release. This affects any external
|
|
18
|
+
tool that tracks survivors by id.
|
|
19
|
+
- Moving or renaming a file now changes its ids.
|
|
20
|
+
- Ids follow the project root: the directory mutineer runs from, or the
|
|
21
|
+
Action's `working-directory`. A run from a different root gives different ids.
|
|
22
|
+
mutineer finds `.mutineer.yml` by walking up, so a run from a subdirectory
|
|
23
|
+
prints one `[mutineer]` warning that the loaded ignore ids will not match.
|
|
24
|
+
- A source outside the project root uses its absolute path, so its ids differ
|
|
25
|
+
between machines.
|
|
26
|
+
- Two methods with the same qualified name in one file (for example two
|
|
27
|
+
top-level `def index` in two DSL blocks) now get different ids. The second
|
|
28
|
+
and later ones hash their position among those methods; the first keeps its
|
|
29
|
+
id.
|
|
30
|
+
- **The JSON report marks its id format** (schema `1.4`, additive).
|
|
31
|
+
`summary.id_format` is `2` for ids that include the file path.
|
|
32
|
+
`summary.legacy_id_matches` counts old-format `ignore:` entries (`ignore`) and
|
|
33
|
+
survivors matched only through an old-format baseline id (`baseline`). The
|
|
34
|
+
GitHub Action shows one warning annotation when either count is not zero.
|
|
35
|
+
|
|
36
|
+
### Deprecated
|
|
37
|
+
- **Old-format ids in `ignore:` and in baselines.** Matching on them is removed
|
|
38
|
+
in 2.0. Replace each old `ignore:` entry with the new ids from the warning
|
|
39
|
+
(only the intended ones when it over-matched several mutants).
|
|
40
|
+
Regenerate a baseline (`--format json`) only after every gate that reads it
|
|
41
|
+
runs this version or later (the Action's `version:` pin, your CI
|
|
42
|
+
`Gemfile.lock`). An older version treats every new-format survivor as new.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- **`module_function :name` and `module_function def name` promote only
|
|
46
|
+
their own module's methods** — a class or module in the same file with a
|
|
47
|
+
method of the same name kept an instance method in Ruby, but mutineer
|
|
48
|
+
named it as a class method. `--strategy redefine` then mutated a method the
|
|
49
|
+
tests never call, so a killable mutant falsely survived, and
|
|
50
|
+
`--only Class#name` selected nothing (#98). The affected methods now get
|
|
51
|
+
their correct names, so their mutant ids change: regenerate any ignore
|
|
52
|
+
entries or baseline survivors that pointed at them.
|
|
53
|
+
- **A root-anchored reopening names the top-level constant** — a
|
|
54
|
+
`module ::Root` or `class ::Solo` written inside another module now gives
|
|
55
|
+
`Root` and `Solo`, not `Outer::Root` and `Outer::Solo`. This applies to every
|
|
56
|
+
method in such a body, with or without `module_function`, so their mutant
|
|
57
|
+
ids change too. `--strategy redefine` rebuilds such a body's scope as
|
|
58
|
+
written (`module Outer` then `class ::Solo`), so a constant from `Outer`
|
|
59
|
+
still resolves in the mutated method, as it does under `reload`. Before,
|
|
60
|
+
the method raised NameError in the test, which counted as a false kill
|
|
61
|
+
(#145).
|
|
62
|
+
- **Old-format ids keep working, with a warning** (#126). An old-format
|
|
63
|
+
`ignore:` entry still suppresses the mutants it matched before. The run prints
|
|
64
|
+
one `[mutineer]` warning per entry with each new id, its file and its method.
|
|
65
|
+
When the entry matched several mutants (in different files, or same-named
|
|
66
|
+
methods in one file), the warning says it
|
|
67
|
+
over-matched and to keep only the ids for the mutant you meant to ignore. A
|
|
68
|
+
`--baseline` file without `summary.id_format` matches on new ids, or on old
|
|
69
|
+
ids from the same file, so no survivor reads as new or fixed only because its
|
|
70
|
+
id changed. A stored file outside the project root (a baseline written on
|
|
71
|
+
another machine) matches on the old id alone. The run prints one
|
|
72
|
+
`[mutineer]` warning to regenerate the baseline.
|
|
73
|
+
|
|
9
74
|
## [1.2.0] - 2026-09-28
|
|
10
75
|
|
|
11
76
|
### Added
|
|
@@ -546,6 +611,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
546
611
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
547
612
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
548
613
|
|
|
614
|
+
[1.3.0]: https://github.com/davidteren/mutineer/releases/tag/v1.3.0
|
|
549
615
|
[1.2.0]: https://github.com/davidteren/mutineer/releases/tag/v1.2.0
|
|
550
616
|
[1.1.0]: https://github.com/davidteren/mutineer/releases/tag/v1.1.0
|
|
551
617
|
[1.0.2]: https://github.com/davidteren/mutineer/releases/tag/v1.0.2
|
data/README.md
CHANGED
|
@@ -214,11 +214,59 @@ Some mutants are equivalent (behaviour-identical) and survive forever — keepin
|
|
|
214
214
|
file off 100%. Suppress them so the score and `--threshold` gate stay meaningful:
|
|
215
215
|
|
|
216
216
|
- **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
|
|
217
|
+
- **Config:** a `.mutineer.yml` `ignore:` list of mutant ids. Each survivor's
|
|
218
218
|
`id` is printed in the JSON report, so copy it straight into `ignore:`.
|
|
219
219
|
|
|
220
220
|
Suppressed mutants are excluded from the score (so 100% becomes reachable).
|
|
221
221
|
|
|
222
|
+
## Mutant ids
|
|
223
|
+
|
|
224
|
+
A mutant id is 12 hex characters. It hashes the file path (relative to the
|
|
225
|
+
project root), the method's qualified name, the operator, the mutated code, and
|
|
226
|
+
the mutant's position among identical mutants in that method. When one file has
|
|
227
|
+
two methods with the same qualified name (for example two top-level `def index`
|
|
228
|
+
in two DSL blocks), the second and later ones also hash their position among
|
|
229
|
+
those methods, so their ids differ. The first one's id does not change. An edit
|
|
230
|
+
outside the method does not change the id. Moving or renaming the file,
|
|
231
|
+
renaming the method or its class, or adding an identical mutant earlier in the
|
|
232
|
+
method does. Adding a method with the same name earlier in the same file also
|
|
233
|
+
does.
|
|
234
|
+
|
|
235
|
+
- The project root is the directory mutineer runs from (in the Action, the
|
|
236
|
+
`working-directory`). Run from the same root to get the same ids.
|
|
237
|
+
- A source outside the project root uses its absolute path, so its ids differ
|
|
238
|
+
between machines.
|
|
239
|
+
|
|
240
|
+
**Migrating from ids without the file path.** Before 1.3, ids did not include
|
|
241
|
+
the file path, so two files could share an id (#126). Old-format ids keep
|
|
242
|
+
working until 2.0, with a warning:
|
|
243
|
+
|
|
244
|
+
- **`ignore:`** An old entry still suppresses its mutants. The run prints the
|
|
245
|
+
new ids for each old entry, each with its file and method. When it names one
|
|
246
|
+
mutant, replace the entry with that id. When it names several (in different
|
|
247
|
+
files, or same-named methods in one file), the old entry over-matched: it also
|
|
248
|
+
hid mutants you did not mean to ignore. The warning says so. Keep only the ids for the mutant you meant to
|
|
249
|
+
ignore, not all of them. The list covers only the sources and operators in
|
|
250
|
+
that run, so run over every source with every operator set you use (for
|
|
251
|
+
example your Tier-2 `--operators`) for the full list.
|
|
252
|
+
- **`--baseline`** An old baseline still matches: a survivor matches a stored
|
|
253
|
+
one with the same old id in the same file. A stored file that is an absolute
|
|
254
|
+
path outside the project root (a baseline written on another machine)
|
|
255
|
+
matches on the old id alone. The run tells you to
|
|
256
|
+
regenerate it. Regenerate it with `--format json`, but only after every gate
|
|
257
|
+
that reads it runs 1.3 or later (the Action's `version:` pin, your CI
|
|
258
|
+
`Gemfile.lock`). An older version treats every new-format survivor as new.
|
|
259
|
+
|
|
260
|
+
The JSON report's `summary.id_format` is `2` for the new format.
|
|
261
|
+
`summary.legacy_id_matches.ignore` counts the old-format ignore entries a run
|
|
262
|
+
matched, and `summary.legacy_id_matches.baseline` counts the survivors matched
|
|
263
|
+
only through an old baseline id.
|
|
264
|
+
|
|
265
|
+
Ids are relative to the directory you run mutineer from. mutineer finds
|
|
266
|
+
`.mutineer.yml` by walking up. When the file it loads is in a parent directory
|
|
267
|
+
(other than your home directory), it warns that the ignore ids will not match
|
|
268
|
+
and tells you which directory to run from.
|
|
269
|
+
|
|
222
270
|
## CI gating
|
|
223
271
|
|
|
224
272
|
Store a JSON run as a baseline, then fail the build only when a PR makes things
|
|
@@ -228,7 +276,7 @@ worse:
|
|
|
228
276
|
mutineer run app/ --baseline .mutineer/baseline.json # exit 1 on NEW survivors or a score drop
|
|
229
277
|
```
|
|
230
278
|
|
|
231
|
-
`--baseline` reports which survivors are new (by
|
|
279
|
+
`--baseline` reports which survivors are new (by [mutant id](#mutant-ids)) and any score drop. It
|
|
232
280
|
combines with `--threshold` (the worse of the two sets the exit code). Pass a
|
|
233
281
|
directory (or several sources) to audit a whole layer in one boot — tests are
|
|
234
282
|
auto-paired by convention and the report breaks down per source.
|
|
@@ -267,7 +315,7 @@ the format.
|
|
|
267
315
|
|
|
268
316
|
## For AI agents & pipelines
|
|
269
317
|
|
|
270
|
-
Mutineer is built for programmatic use — versioned JSON,
|
|
318
|
+
Mutineer is built for programmatic use — versioned JSON, [mutant ids](#mutant-ids) that survive unrelated edits,
|
|
271
319
|
structured exit codes, and diff-scoped runs. See:
|
|
272
320
|
|
|
273
321
|
- **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
|
data/lib/mutineer/cli.rb
CHANGED
|
@@ -162,6 +162,7 @@ module Mutineer
|
|
|
162
162
|
|
|
163
163
|
case argv.first
|
|
164
164
|
when "run"
|
|
165
|
+
warn_config_root_mismatch(file_path, config.project_root) if file_path
|
|
165
166
|
# A directory source expands to its **/*.rb files; literal files pass
|
|
166
167
|
# through. Test inference (when --test is omitted) happens in validate!.
|
|
167
168
|
config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
|
|
@@ -469,7 +470,8 @@ module Mutineer
|
|
|
469
470
|
exit 2
|
|
470
471
|
end
|
|
471
472
|
|
|
472
|
-
aggregate, source_map = Runner.execute(config)
|
|
473
|
+
aggregate, source_map, extras = Runner.execute(config)
|
|
474
|
+
warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
|
|
473
475
|
reporter = Reporter.new(aggregate, source_map)
|
|
474
476
|
|
|
475
477
|
# Diff the current run against the baseline (preflighted above) by the
|
|
@@ -479,12 +481,18 @@ module Mutineer
|
|
|
479
481
|
# so only the new-survivor half of the gate applies (see Baseline#diff).
|
|
480
482
|
delta = if config.baseline
|
|
481
483
|
Baseline.load(config.baseline).diff(aggregate, epsilon: config.baseline_epsilon,
|
|
482
|
-
scoped: !config.since.nil
|
|
484
|
+
scoped: !config.since.nil?,
|
|
485
|
+
id_map: extras[:id_map],
|
|
486
|
+
project_root: config.project_root)
|
|
483
487
|
end
|
|
488
|
+
warn_legacy_baseline if delta&.legacy_matches&.positive?
|
|
484
489
|
|
|
490
|
+
# ignore counts old-format entries (one warning each), not the ids they matched.
|
|
491
|
+
legacy_id_matches = { ignore: extras[:legacy_ignore_matches].size,
|
|
492
|
+
baseline: delta ? delta.legacy_matches : 0 }
|
|
485
493
|
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
486
494
|
format: config.format, output: config.output, baseline: delta,
|
|
487
|
-
scoped: !config.since.nil
|
|
495
|
+
scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches)
|
|
488
496
|
|
|
489
497
|
# Warn (stderr, so it never pollutes json/html) that an external run's score
|
|
490
498
|
# is not comparable to an in-process run: no coverage narrowing (uncovered
|
|
@@ -523,6 +531,64 @@ module Mutineer
|
|
|
523
531
|
"enable with --operators <list>."
|
|
524
532
|
end
|
|
525
533
|
|
|
534
|
+
# Warns once when the loaded .mutineer.yml sits outside the run directory
|
|
535
|
+
# (#126). Mutant ids hash each file's path relative to the run directory,
|
|
536
|
+
# but the config is found by walking up, so a run from a subdirectory loads
|
|
537
|
+
# the same ignore list while its ids no longer match. A config in the home
|
|
538
|
+
# directory is a personal default, not a project root, so it never warns.
|
|
539
|
+
#
|
|
540
|
+
# @param file_path [String] the .mutineer.yml that was loaded.
|
|
541
|
+
# @param project_root [String] the run directory ids are relative to.
|
|
542
|
+
# @return [void]
|
|
543
|
+
def self.warn_config_root_mismatch(file_path, project_root)
|
|
544
|
+
config_dir = ProjectPath.root_real(File.dirname(file_path))
|
|
545
|
+
return if config_dir == ProjectPath.root_real(project_root)
|
|
546
|
+
return if config_dir == ProjectPath.root_real(Dir.home)
|
|
547
|
+
|
|
548
|
+
warn "[mutineer] loaded #{file_path}, but mutant ids are relative to the run directory " \
|
|
549
|
+
"#{project_root}, not to #{config_dir}. Ignore ids and baselines written from " \
|
|
550
|
+
"#{config_dir} will not match this run. Run mutineer from #{config_dir}."
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Warns once per old-format `ignore:` entry (#126), naming each new id it
|
|
554
|
+
# matched with that mutant's file and subject. mutineer cannot tell a full
|
|
555
|
+
# run from a narrowed one, so the text always says the list covers only this
|
|
556
|
+
# run's mutants. An entry that matched more than one distinct mutant (in
|
|
557
|
+
# other files, or same-named methods in one file) over-matched: the old id
|
|
558
|
+
# could not tell them apart, so replacing it with every new id would keep
|
|
559
|
+
# suppressing the mutants it hid by accident.
|
|
560
|
+
#
|
|
561
|
+
# @param matches [Hash{String => Array<Hash{Symbol => String}>}] old-format
|
|
562
|
+
# entry => one `{id:, file:, subject:}` hash per matched mutant.
|
|
563
|
+
# @return [void]
|
|
564
|
+
def self.warn_legacy_ignore_matches(matches)
|
|
565
|
+
matches.each do |old, hits|
|
|
566
|
+
listed = hits.map { |h| "#{h[:id]} (#{h[:file]}, #{h[:subject]})" }.join(", ")
|
|
567
|
+
advice = if hits.map { |h| h[:id] }.uniq.size > 1
|
|
568
|
+
"#{old} over-matched: the old format could not tell these mutants apart. " \
|
|
569
|
+
"Replace #{old} and keep only the ids for the mutant you meant to ignore, not all of them."
|
|
570
|
+
else
|
|
571
|
+
"Replace #{old} with the new ids in your ignore list."
|
|
572
|
+
end
|
|
573
|
+
warn "[mutineer] ignore entry #{old} uses the old id format, which did not include the " \
|
|
574
|
+
"file path. It matched these new ids: #{listed}. This list covers only mutants in " \
|
|
575
|
+
"this run's sources and operators; a run over every source gives the complete " \
|
|
576
|
+
"replacement. #{advice}"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Warns once that the --baseline file stores old-format ids (#126), so the
|
|
581
|
+
# diff fell back to matching on them. Called only when a survivor matched
|
|
582
|
+
# through an old id alone.
|
|
583
|
+
#
|
|
584
|
+
# @return [void]
|
|
585
|
+
def self.warn_legacy_baseline
|
|
586
|
+
warn "[mutineer] the baseline uses the old id format, which did not include the file " \
|
|
587
|
+
"path, so survivors were matched on their old ids and files. Regenerate the baseline " \
|
|
588
|
+
"(run with --format json and save the output), but only after every gate that reads " \
|
|
589
|
+
"it runs this mutineer version or later."
|
|
590
|
+
end
|
|
591
|
+
|
|
526
592
|
# Runs dry-run mode. Reuses Runner.collect_jobs (+ filter_since) so the
|
|
527
593
|
# candidate list cannot drift from a real run's job selection.
|
|
528
594
|
#
|
|
@@ -530,7 +596,8 @@ module Mutineer
|
|
|
530
596
|
# @return [void]
|
|
531
597
|
def self.dry_run(config)
|
|
532
598
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
533
|
-
jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
|
|
599
|
+
jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
|
|
600
|
+
warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
|
|
534
601
|
# Narrow jobs and ignored the same way so the summary matches the printed list.
|
|
535
602
|
if config.since
|
|
536
603
|
jobs = Runner.filter_since(jobs, source_map, config)
|
|
@@ -9,6 +9,7 @@ require "set"
|
|
|
9
9
|
require_relative "minitest_integration"
|
|
10
10
|
require_relative "test_runners"
|
|
11
11
|
require_relative "child_stdout"
|
|
12
|
+
require_relative "project_path"
|
|
12
13
|
|
|
13
14
|
module Mutineer
|
|
14
15
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
@@ -722,11 +723,7 @@ module Mutineer
|
|
|
722
723
|
#
|
|
723
724
|
# @api private
|
|
724
725
|
# @return [String] realpath of the project root when it exists.
|
|
725
|
-
def project_root_real
|
|
726
|
-
File.realpath(File.expand_path(@project_root))
|
|
727
|
-
rescue Errno::ENOENT
|
|
728
|
-
File.expand_path(@project_root)
|
|
729
|
-
end
|
|
726
|
+
def project_root_real = ProjectPath.root_real(@project_root)
|
|
730
727
|
|
|
731
728
|
# Project-local `.rb` files loaded in this process at capture time.
|
|
732
729
|
#
|
|
@@ -907,38 +904,18 @@ module Mutineer
|
|
|
907
904
|
# @return [Array<String>] absolute load paths.
|
|
908
905
|
def abs_load_paths = @load_paths.map { |p| absolute(p) }
|
|
909
906
|
|
|
910
|
-
# Relativizes a path against the project root.
|
|
907
|
+
# Relativizes a path against the project root (see {ProjectPath.relative}).
|
|
911
908
|
#
|
|
912
909
|
# @api private
|
|
913
910
|
# @param path [String] path to relativize.
|
|
914
|
-
# @return [String] relative path.
|
|
915
|
-
def relativize(path)
|
|
916
|
-
abs = path.start_with?("/") ? path : absolute(path)
|
|
917
|
-
abs = realpath_if_exists(abs)
|
|
918
|
-
root = project_root_real
|
|
919
|
-
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
920
|
-
return abs unless abs.start_with?(prefix)
|
|
921
|
-
|
|
922
|
-
abs.delete_prefix(prefix)
|
|
923
|
-
end
|
|
911
|
+
# @return [String] relative path, or an absolute path when outside the root.
|
|
912
|
+
def relativize(path) = ProjectPath.relative(path, @project_root)
|
|
924
913
|
|
|
925
|
-
# Expands a path relative to the project root.
|
|
914
|
+
# Expands a path relative to the project root (see {ProjectPath.absolute}).
|
|
926
915
|
#
|
|
927
916
|
# @api private
|
|
928
917
|
# @param path [String] path to expand.
|
|
929
918
|
# @return [String] absolute path.
|
|
930
|
-
def absolute(path)
|
|
931
|
-
raw = File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
932
|
-
realpath_if_exists(raw)
|
|
933
|
-
end
|
|
934
|
-
|
|
935
|
-
# Real path when the file exists, otherwise `path` unchanged.
|
|
936
|
-
#
|
|
937
|
-
# @api private
|
|
938
|
-
# @param path [String] absolute or relative path.
|
|
939
|
-
# @return [String]
|
|
940
|
-
def realpath_if_exists(path)
|
|
941
|
-
File.exist?(path) ? File.realpath(path) : path
|
|
942
|
-
end
|
|
919
|
+
def absolute(path) = ProjectPath.absolute(path, @project_root)
|
|
943
920
|
end
|
|
944
921
|
end
|
|
@@ -43,9 +43,10 @@ module Mutineer
|
|
|
43
43
|
#
|
|
44
44
|
# @param config [Mutineer::Config] run configuration (daemon set).
|
|
45
45
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
46
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String,String
|
|
46
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
47
|
+
# source map, and the {Runner.collect_jobs} extras.
|
|
47
48
|
def self.execute(config, operator_classes)
|
|
48
|
-
jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
|
|
49
|
+
jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
|
|
49
50
|
jobs = Runner.filter_since(jobs, source_map, config) if config.since
|
|
50
51
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
51
52
|
|
|
@@ -58,7 +59,7 @@ module Mutineer
|
|
|
58
59
|
# tool-side. A file a hard-killed run left in app/models breaks the app's own
|
|
59
60
|
# Zeitwerk boot, not just Mutineer's next run.
|
|
60
61
|
Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
|
|
61
|
-
return [AggregateResult.new(ignored_results), source_map]
|
|
62
|
+
return [AggregateResult.new(ignored_results), source_map, extras]
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
# Build the coverage map once (app-side). nil when the build fails: runners
|
|
@@ -84,7 +85,7 @@ module Mutineer
|
|
|
84
85
|
run_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
88
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Mutineer
|
|
|
132
132
|
# namespace constants resolve exactly as the reload strategy would. A
|
|
133
133
|
# bare redefinition on the owner would collapse Module.nesting to [owner]
|
|
134
134
|
# and raise NameError on such constants (C2 scope-collapse).
|
|
135
|
-
keywords = nesting_keywords(subject.
|
|
135
|
+
keywords = nesting_keywords(subject.lexical_namespace)
|
|
136
136
|
prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
|
|
137
137
|
prefix += "\n" unless prefix.empty?
|
|
138
138
|
|
|
@@ -188,13 +188,18 @@ module Mutineer
|
|
|
188
188
|
# Foo], so an unqualified constant defined only in Foo would resolve under
|
|
189
189
|
# redefine but not reload — a strategy disagreement.
|
|
190
190
|
#
|
|
191
|
+
# A root-anchored element (`::Top`, #145) resolves from Object and keeps its
|
|
192
|
+
# `::` in the wrapper, so `module Outer; class ::Top` rebuilds nesting
|
|
193
|
+
# [Top, Outer] exactly as the source does.
|
|
194
|
+
#
|
|
191
195
|
# @api private
|
|
192
|
-
# @param namespace [Array<String>]
|
|
196
|
+
# @param namespace [Array<String>] class/module chain as written.
|
|
193
197
|
# @return [Array<[String, String]>] wrapper keywords and names.
|
|
194
198
|
def self.nesting_keywords(namespace)
|
|
195
199
|
mod = Object
|
|
196
200
|
namespace.map do |name|
|
|
197
|
-
|
|
201
|
+
# const_get resolves a compact "Foo::Bar" too
|
|
202
|
+
mod = name.start_with?("::") ? Object.const_get(name.delete_prefix("::")) : mod.const_get(name)
|
|
198
203
|
[mod.is_a?(Class) ? "class" : "module", name]
|
|
199
204
|
end
|
|
200
205
|
end
|
data/lib/mutineer/mutant_id.rb
CHANGED
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
|
-
# Content-based
|
|
6
|
+
# Content-based id for a mutant — NOT byte offsets. Pure function, reused
|
|
7
7
|
# by the Runner (matching the ignore list), the Reporter (emitting a copy-
|
|
8
8
|
# pasteable id per survivor), and #13 baseline gating (diffing id-sets run to
|
|
9
9
|
# run). `digest` is stdlib, so zero new deps.
|
|
10
10
|
#
|
|
11
|
-
# Offset-free by design: keyed on the subject's
|
|
12
|
-
# byte position) + operator + the normalized
|
|
13
|
-
# ordinal among same-(operator, token) twins
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# Offset-free by design: keyed on the subject's project-relative file path +
|
|
12
|
+
# qualified_name (a method, not a byte position) + operator + the normalized
|
|
13
|
+
# mutated token + an occurrence ordinal among same-(operator, token) twins
|
|
14
|
+
# WITHIN the subject + (only when positive) the subject's ordinal among
|
|
15
|
+
# same-named subjects in its file. So it survives any edit outside the subject method,
|
|
16
|
+
# where raw start/end offsets shift on every edit earlier in the file and
|
|
17
|
+
# would silently stop matching. Moving or renaming the file changes the id.
|
|
16
18
|
module MutantId
|
|
17
19
|
module_function
|
|
18
20
|
|
|
@@ -20,6 +22,9 @@ module Mutineer
|
|
|
20
22
|
#
|
|
21
23
|
# NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
|
|
22
24
|
# with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
|
|
25
|
+
# The file path is part of the key, so the same qualified name in two files
|
|
26
|
+
# (an owner-less `def`, or a class reopened elsewhere) cannot collide. `path`
|
|
27
|
+
# is required so no caller can get the colliding legacy id by accident.
|
|
23
28
|
#
|
|
24
29
|
# @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
|
|
25
30
|
# its `qualified_name` anchors the id to a method rather than a byte position.
|
|
@@ -27,12 +32,17 @@ module Mutineer
|
|
|
27
32
|
# @param source [String] the full, unmutated source the mutation indexes into.
|
|
28
33
|
# @param occurrence [Integer] 0-based ordinal among twins sharing the same
|
|
29
34
|
# (operator, token) within the subject, disambiguating otherwise-identical mutants.
|
|
35
|
+
# @param path [String] the subject's file, normalized with {ProjectPath.relative}
|
|
36
|
+
# against the project root (an absolute real path when outside the root).
|
|
37
|
+
# @param subject_ordinal [Integer] 0-based ordinal among subjects in the same
|
|
38
|
+
# file sharing this qualified name (two owner-less `def index` in two DSL
|
|
39
|
+
# blocks). Hashed only when positive, so a subject whose name is unique in
|
|
40
|
+
# its file keeps the id it had without it.
|
|
30
41
|
# @return [String] a 12-character hex id, stable across edits outside the subject.
|
|
31
|
-
def for(subject, mutation, source, occurrence = 0)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)[0, 12]
|
|
42
|
+
def for(subject, mutation, source, occurrence = 0, path:, subject_ordinal: 0)
|
|
43
|
+
parts = [path, subject.qualified_name, mutation.operator, normalized_token(mutation, source), occurrence]
|
|
44
|
+
parts << subject_ordinal if subject_ordinal.positive?
|
|
45
|
+
digest(parts)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
48
|
# Computes ids for a subject's full mutation list, in input order, assigning
|
|
@@ -42,17 +52,66 @@ module Mutineer
|
|
|
42
52
|
# @param subject [Mutineer::Subject] the subject the mutations belong to.
|
|
43
53
|
# @param source [String] the full, unmutated source for token normalization.
|
|
44
54
|
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
55
|
+
# @param path [String] the subject's normalized file path (see {.for}).
|
|
56
|
+
# @param subject_ordinal [Integer] the subject's ordinal among same-named
|
|
57
|
+
# subjects in its file (see {.for}).
|
|
45
58
|
# @return [Array<String>] one 12-character id per mutation, positionally aligned.
|
|
46
|
-
def for_subject(subject, source, mutations)
|
|
59
|
+
def for_subject(subject, source, mutations, path:, subject_ordinal: 0)
|
|
60
|
+
with_occurrences(mutations, source) do |m, occ|
|
|
61
|
+
self.for(subject, m, source, occ, path: path, subject_ordinal: subject_ordinal)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The pre-1.3 id: the {.for} formula without the path, so it collides across
|
|
66
|
+
# files. Kept only to match ignore entries and baselines stored in the old
|
|
67
|
+
# format; removed in 2.0.
|
|
68
|
+
#
|
|
69
|
+
# @param subject [Mutineer::Subject] the subject (method) the mutant lives in.
|
|
70
|
+
# @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
|
|
71
|
+
# @param source [String] the full, unmutated source the mutation indexes into.
|
|
72
|
+
# @param occurrence [Integer] 0-based ordinal among same-(operator, token) twins.
|
|
73
|
+
# @return [String] a 12-character hex id in the old format.
|
|
74
|
+
def legacy_for(subject, mutation, source, occurrence = 0)
|
|
75
|
+
digest([subject.qualified_name, mutation.operator,
|
|
76
|
+
normalized_token(mutation, source), occurrence])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# {.for_subject} for the pre-1.3 id format (see {.legacy_for}).
|
|
80
|
+
#
|
|
81
|
+
# @param subject [Mutineer::Subject] the subject the mutations belong to.
|
|
82
|
+
# @param source [String] the full, unmutated source for token normalization.
|
|
83
|
+
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
84
|
+
# @return [Array<String>] one old-format id per mutation, positionally aligned.
|
|
85
|
+
def legacy_for_subject(subject, source, mutations)
|
|
86
|
+
with_occurrences(mutations, source) { |m, occ| legacy_for(subject, m, source, occ) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Maps each mutation to the block's result, passing its 0-based occurrence
|
|
90
|
+
# among earlier mutations with the same (operator, token).
|
|
91
|
+
#
|
|
92
|
+
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
93
|
+
# @param source [String] the full, unmutated source for token normalization.
|
|
94
|
+
# @yieldparam mutation [Mutineer::Mutation] the current mutation.
|
|
95
|
+
# @yieldparam occurrence [Integer] its ordinal among same-(operator, token) twins.
|
|
96
|
+
# @return [Array] the block's results, positionally aligned.
|
|
97
|
+
def with_occurrences(mutations, source)
|
|
47
98
|
seen = Hash.new(0)
|
|
48
99
|
mutations.map do |m|
|
|
49
100
|
key = [m.operator, normalized_token(m, source)]
|
|
50
101
|
occ = seen[key]
|
|
51
102
|
seen[key] += 1
|
|
52
|
-
|
|
103
|
+
yield m, occ
|
|
53
104
|
end
|
|
54
105
|
end
|
|
55
106
|
|
|
107
|
+
# NUL-joins the id parts and returns the first 12 hex chars of their SHA256.
|
|
108
|
+
#
|
|
109
|
+
# @param parts [Array] the values that make up the id.
|
|
110
|
+
# @return [String] a 12-character hex id.
|
|
111
|
+
def digest(parts)
|
|
112
|
+
Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]
|
|
113
|
+
end
|
|
114
|
+
|
|
56
115
|
# Extracts the exact code being mutated, whitespace-collapsed — the same
|
|
57
116
|
# normalization the Reporter's `diff_for` uses for its token label.
|
|
58
117
|
#
|
data/lib/mutineer/project.rb
CHANGED
|
@@ -36,23 +36,26 @@ module Mutineer
|
|
|
36
36
|
def initialize(file)
|
|
37
37
|
@file = file
|
|
38
38
|
@namespace_stack = []
|
|
39
|
+
@lexical_stack = [] # class/module names as written, `::X` kept (#145)
|
|
39
40
|
@subjects = []
|
|
40
41
|
@singleton_depth = 0
|
|
41
42
|
@module_function_active = false # bareword `module_function` seen in this module body
|
|
42
|
-
@module_function_names = [] #
|
|
43
|
+
@module_function_names = [] # [namespace, name] from `module_function :a` / `module_function def` (#98)
|
|
43
44
|
super()
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# Promote `module_function :name` / `module_function def name` subjects to
|
|
47
48
|
# singleton after the full walk — the naming call may appear before or after
|
|
48
|
-
# the def, so it can't be decided at visit_def_node time (#20).
|
|
49
|
+
# the def, so it can't be decided at visit_def_node time (#20). Only methods
|
|
50
|
+
# of the module that made the call are promoted (#98); namespaces compare
|
|
51
|
+
# joined, since `module A::B` and nested `module A; module B` differ as arrays.
|
|
49
52
|
#
|
|
50
53
|
# @return [void]
|
|
51
54
|
def promote_module_functions!
|
|
52
55
|
return if @module_function_names.empty?
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
@subjects.each { |s| s.singleton = true if
|
|
57
|
+
named = @module_function_names.to_set
|
|
58
|
+
@subjects.each { |s| s.singleton = true if named.include?([s.namespace.join("::"), s.name]) }
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
# Visits class nodes and tracks namespace nesting.
|
|
@@ -60,12 +63,7 @@ module Mutineer
|
|
|
60
63
|
# @param node [Prism::ClassNode] class node.
|
|
61
64
|
# @return [void]
|
|
62
65
|
def visit_class_node(node)
|
|
63
|
-
|
|
64
|
-
saved = @module_function_active
|
|
65
|
-
@module_function_active = false # module_function state does not cross a class boundary
|
|
66
|
-
super
|
|
67
|
-
@module_function_active = saved
|
|
68
|
-
@namespace_stack.pop
|
|
66
|
+
with_namespace(node.constant_path) { super }
|
|
69
67
|
end
|
|
70
68
|
|
|
71
69
|
# Visits module nodes and tracks namespace nesting.
|
|
@@ -73,12 +71,7 @@ module Mutineer
|
|
|
73
71
|
# @param node [Prism::ModuleNode] module node.
|
|
74
72
|
# @return [void]
|
|
75
73
|
def visit_module_node(node)
|
|
76
|
-
|
|
77
|
-
saved = @module_function_active
|
|
78
|
-
@module_function_active = false # each module body starts without module_function active
|
|
79
|
-
super
|
|
80
|
-
@module_function_active = saved
|
|
81
|
-
@namespace_stack.pop
|
|
74
|
+
with_namespace(node.constant_path) { super }
|
|
82
75
|
end
|
|
83
76
|
|
|
84
77
|
# Track `module_function` so its methods are recorded as singletons (#20) —
|
|
@@ -94,9 +87,10 @@ module Mutineer
|
|
|
94
87
|
if args.empty?
|
|
95
88
|
@module_function_active = true
|
|
96
89
|
else
|
|
90
|
+
namespace = @namespace_stack.join("::")
|
|
97
91
|
args.each do |arg|
|
|
98
|
-
@module_function_names << arg.value.to_sym if arg.is_a?(Prism::SymbolNode)
|
|
99
|
-
@module_function_names << arg.name if arg.is_a?(Prism::DefNode)
|
|
92
|
+
@module_function_names << [namespace, arg.value.to_sym] if arg.is_a?(Prism::SymbolNode)
|
|
93
|
+
@module_function_names << [namespace, arg.name] if arg.is_a?(Prism::DefNode)
|
|
100
94
|
end
|
|
101
95
|
end
|
|
102
96
|
end
|
|
@@ -127,6 +121,7 @@ module Mutineer
|
|
|
127
121
|
@subjects << Subject.new(
|
|
128
122
|
file: @file,
|
|
129
123
|
namespace: @namespace_stack.dup,
|
|
124
|
+
lexical: @lexical_stack.dup,
|
|
130
125
|
name: node.name,
|
|
131
126
|
singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
|
|
132
127
|
def_node: node
|
|
@@ -136,6 +131,40 @@ module Mutineer
|
|
|
136
131
|
|
|
137
132
|
private
|
|
138
133
|
|
|
134
|
+
# Runs the block with `path` pushed as the current namespace. A
|
|
135
|
+
# root-anchored path (`module ::X` / `class ::X`) names the top-level X,
|
|
136
|
+
# not X nested in the enclosing scope, so the namespace restarts there.
|
|
137
|
+
# Bareword `module_function` state does not cross a class or module
|
|
138
|
+
# boundary: each body starts without it, and the outer state returns after.
|
|
139
|
+
#
|
|
140
|
+
# @param path [Prism::Node] the class/module constant path.
|
|
141
|
+
# @yield the class or module body visit.
|
|
142
|
+
# @return [void]
|
|
143
|
+
def with_namespace(path)
|
|
144
|
+
saved_stack = @namespace_stack
|
|
145
|
+
saved_lexical = @lexical_stack
|
|
146
|
+
saved_active = @module_function_active
|
|
147
|
+
name = extract_constant_name(path)
|
|
148
|
+
root = root_anchored?(path)
|
|
149
|
+
@namespace_stack = root ? [name] : saved_stack + [name]
|
|
150
|
+
@lexical_stack = saved_lexical + [root ? "::#{name}" : name]
|
|
151
|
+
@module_function_active = false
|
|
152
|
+
yield
|
|
153
|
+
ensure
|
|
154
|
+
@namespace_stack = saved_stack
|
|
155
|
+
@lexical_stack = saved_lexical
|
|
156
|
+
@module_function_active = saved_active
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# True when a constant path starts with `::` (e.g. `::X` or `::A::B`).
|
|
160
|
+
#
|
|
161
|
+
# @param node [Prism::Node] constant path node.
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def root_anchored?(node)
|
|
164
|
+
node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
|
|
165
|
+
node.is_a?(Prism::ConstantPathNode)
|
|
166
|
+
end
|
|
167
|
+
|
|
139
168
|
# Extracts a constant name from a Prism constant node.
|
|
140
169
|
#
|
|
141
170
|
# @api private
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Realpath-based path normalization against a project root. Shared by the
|
|
5
|
+
# coverage cache (map keys) and mutant ids (the path hashed into each id), so
|
|
6
|
+
# `lib/x.rb`, `./lib/x.rb`, an absolute path and a path through a symlinked
|
|
7
|
+
# root all resolve to the same key. Realpaths on both sides also absorb the
|
|
8
|
+
# macOS `/var` vs `/private/var` alias.
|
|
9
|
+
module ProjectPath
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Path of `path` relative to the real project root. A path outside the root
|
|
13
|
+
# comes back as its absolute real path.
|
|
14
|
+
#
|
|
15
|
+
# @param path [String] relative (to `root`) or absolute path.
|
|
16
|
+
# @param root [String] project root.
|
|
17
|
+
# @return [String] root-relative path, or an absolute path when outside `root`.
|
|
18
|
+
def relative(path, root)
|
|
19
|
+
abs = absolute(path, root)
|
|
20
|
+
real_root = root_real(root)
|
|
21
|
+
prefix = real_root.end_with?("/") ? real_root : "#{real_root}/"
|
|
22
|
+
return abs unless abs.start_with?(prefix)
|
|
23
|
+
|
|
24
|
+
abs.delete_prefix(prefix)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Expands `path` against `root`, resolved to its real path when it exists.
|
|
28
|
+
#
|
|
29
|
+
# @param path [String] relative (to `root`) or absolute path.
|
|
30
|
+
# @param root [String] project root.
|
|
31
|
+
# @return [String] absolute path.
|
|
32
|
+
def absolute(path, root)
|
|
33
|
+
# Join, don't expand: File.expand_path collapses `..` textually, before any
|
|
34
|
+
# symlink is followed, so `link/../x.rb` would name the wrong file. The file
|
|
35
|
+
# system resolves `..` physically in File.realpath.
|
|
36
|
+
# A leading `~` names the home directory (as File.expand_path reads it), so
|
|
37
|
+
# expand it rather than joining it under the root.
|
|
38
|
+
raw = if File.absolute_path?(path) then path
|
|
39
|
+
elsif path.start_with?("~") then File.expand_path(path)
|
|
40
|
+
else File.join(File.expand_path(root), path)
|
|
41
|
+
end
|
|
42
|
+
File.exist?(raw) ? File.realpath(raw) : File.expand_path(raw)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Canonical project root (`/var` vs `/private/var`). Any file system error
|
|
46
|
+
# (missing, unreadable, a symlink loop) falls back to the expanded path, so
|
|
47
|
+
# a caller such as the CLI's run-root warning never crashes on it.
|
|
48
|
+
#
|
|
49
|
+
# @param root [String] project root.
|
|
50
|
+
# @return [String] realpath of the root when it exists, else its expanded path.
|
|
51
|
+
def root_real(root)
|
|
52
|
+
File.realpath(File.expand_path(root))
|
|
53
|
+
rescue SystemCallError
|
|
54
|
+
File.expand_path(root)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -35,11 +35,13 @@ module Mutineer
|
|
|
35
35
|
# or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
|
|
36
36
|
# (`--since`) run; the JSON report records it so a consumer (or a later
|
|
37
37
|
# `--baseline` load) knows the score covers only the changed-line mutants.
|
|
38
|
+
# `legacy_id_matches` (`{ignore:, baseline:}`) counts stored ids still in the
|
|
39
|
+
# old format (#126); only the JSON report records it (`summary.legacy_id_matches`).
|
|
38
40
|
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
|
|
39
|
-
baseline: nil, scoped: false)
|
|
41
|
+
baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
|
|
40
42
|
rendered =
|
|
41
43
|
if format == "json"
|
|
42
|
-
json_report(baseline, scoped: scoped)
|
|
44
|
+
json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
|
|
43
45
|
elsif format == "html"
|
|
44
46
|
html_report
|
|
45
47
|
else
|
|
@@ -131,8 +133,11 @@ module Mutineer
|
|
|
131
133
|
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
132
134
|
# @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
|
|
133
135
|
# covers only the changed-line mutants (additive `summary.scoped` key).
|
|
136
|
+
# @param legacy_id_matches [Hash{Symbol => Integer}] `{ignore:, baseline:}`:
|
|
137
|
+
# old-format ignore entries that matched, and survivors matched in an
|
|
138
|
+
# old-format baseline only through their old id (#126).
|
|
134
139
|
# @return [String] JSON text.
|
|
135
|
-
def json_report(baseline = nil, scoped: false)
|
|
140
|
+
def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
|
|
136
141
|
killed = @agg.killed_count
|
|
137
142
|
survived = @agg.survived_count
|
|
138
143
|
# null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
@@ -141,7 +146,7 @@ module Mutineer
|
|
|
141
146
|
score = @agg.mutation_score
|
|
142
147
|
|
|
143
148
|
doc = {
|
|
144
|
-
schema_version: "1.
|
|
149
|
+
schema_version: "1.4",
|
|
145
150
|
summary: {
|
|
146
151
|
total: @agg.total, killed: killed, survived: survived,
|
|
147
152
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -155,7 +160,15 @@ module Mutineer
|
|
|
155
160
|
# Additive: true when the run was diff-scoped (--since). The score then
|
|
156
161
|
# covers only the changed-line mutants, so it is not comparable to a
|
|
157
162
|
# full-run score; Baseline#diff reads this to skip the score-drop gate.
|
|
158
|
-
scoped: scoped
|
|
163
|
+
scoped: scoped,
|
|
164
|
+
# Additive (1.4, #126): ids hash the project-relative file path. A
|
|
165
|
+
# baseline without this key stores old-format ids; Baseline#diff then
|
|
166
|
+
# also matches on old ids.
|
|
167
|
+
id_format: 2,
|
|
168
|
+
# Additive (1.4, #126): stored ids still in the old format. `ignore` is
|
|
169
|
+
# the number of old-format ignore entries that matched; `baseline` the
|
|
170
|
+
# survivors matched in the baseline only through their old id.
|
|
171
|
+
legacy_id_matches: legacy_id_matches
|
|
159
172
|
},
|
|
160
173
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
161
174
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -28,8 +28,8 @@ module Mutineer
|
|
|
28
28
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
29
|
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
30
30
|
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
31
|
-
# can render survivor diffs and emit the
|
|
32
|
-
# MutantId.
|
|
31
|
+
# can render survivor diffs and emit the id. `id` is the content-based
|
|
32
|
+
# MutantId (it includes the project-relative file path).
|
|
33
33
|
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
34
34
|
# Builds a killed result.
|
|
35
35
|
#
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "mutator_registry"
|
|
|
13
13
|
require_relative "worker_pool"
|
|
14
14
|
require_relative "progress"
|
|
15
15
|
require_relative "mutant_id"
|
|
16
|
+
require_relative "project_path"
|
|
16
17
|
require_relative "file_swap"
|
|
17
18
|
require_relative "external_backend"
|
|
18
19
|
require_relative "daemon_backend"
|
|
@@ -31,15 +32,18 @@ module Mutineer
|
|
|
31
32
|
class Runner
|
|
32
33
|
# Full orchestration: resolve operators, discover subjects, build the
|
|
33
34
|
# coverage map, run every mutation, and aggregate. Returns
|
|
34
|
-
# [AggregateResult, source_map]
|
|
35
|
-
#
|
|
35
|
+
# [AggregateResult, source_map, extras], where extras is the hash
|
|
36
|
+
# {.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
|
|
37
|
+
# The CLI then reports + applies the exit code; the integration test asserts
|
|
38
|
+
# directly on the AggregateResult.
|
|
36
39
|
#
|
|
37
40
|
# The parent process `require`s each source file so its classes exist; forked
|
|
38
41
|
# children inherit them, so a covering test file's own require_relative of the
|
|
39
42
|
# source is a no-op and does not clobber the mutated `load` (spec §7).
|
|
40
43
|
#
|
|
41
44
|
# @param config [Mutineer::Config] run configuration.
|
|
42
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String, String
|
|
45
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
|
|
46
|
+
# source map, and run extras.
|
|
43
47
|
def self.execute(config)
|
|
44
48
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
45
49
|
|
|
@@ -109,7 +113,7 @@ module Mutineer
|
|
|
109
113
|
abort_if_unclean!(coverage_map)
|
|
110
114
|
|
|
111
115
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
112
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
116
|
+
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
113
117
|
|
|
114
118
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
115
119
|
|
|
@@ -144,40 +148,75 @@ module Mutineer
|
|
|
144
148
|
sweep_orphans(dirs)
|
|
145
149
|
end
|
|
146
150
|
|
|
147
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
151
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
151
155
|
# A mutant the user marked known-equivalent (inline disable-line comment or
|
|
152
156
|
# .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
|
|
153
157
|
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
154
|
-
# The
|
|
155
|
-
#
|
|
156
|
-
# the
|
|
158
|
+
# The id is computed per subject (occurrence needs the full list), keyed on
|
|
159
|
+
# the file path relative to config.project_root, and carried on every job so
|
|
160
|
+
# the parent can reattach it after the run. Shared by the in-process,
|
|
161
|
+
# external, and daemon backends so job selection can never drift.
|
|
157
162
|
#
|
|
158
|
-
#
|
|
163
|
+
# Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
|
|
164
|
+
# entry stored before ids carried the path still suppresses it. Prints nothing:
|
|
165
|
+
# the extras hash returns, as data, `legacy_ignore_matches` (each old-format
|
|
166
|
+
# ignore entry that matched a mutant through its old-format id => one
|
|
167
|
+
# `{id:, file:, subject:}` hash per matched mutant, in collection order: its
|
|
168
|
+
# new id, its project-relative file and its subject's qualified name;
|
|
169
|
+
# recorded even when a new id is also listed, since the old entry still
|
|
170
|
+
# over-matches other files) and `id_map` (every new id => its old-format id).
|
|
171
|
+
#
|
|
172
|
+
# Subjects sharing a qualified name in one file (two owner-less `def index`
|
|
173
|
+
# in two DSL blocks) get a per-file ordinal in discovery order, so their ids
|
|
174
|
+
# differ; the first one's ordinal is 0 and leaves its id unchanged.
|
|
175
|
+
#
|
|
176
|
+
# @param config [Mutineer::Config] run configuration.
|
|
177
|
+
# @param operator_classes [Array<Class>] resolved operators.
|
|
178
|
+
# @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
|
|
179
|
+
# jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
|
|
159
180
|
def self.collect_jobs(config, operator_classes)
|
|
160
181
|
source_map = {}
|
|
161
182
|
disabled_map = {}
|
|
183
|
+
id_paths = {}
|
|
184
|
+
# [file, qualified_name] => { declaration offset => ordinal }: keyed by the
|
|
185
|
+
# declaration, so the same file discovered twice (two path spellings) reuses
|
|
186
|
+
# its ordinal instead of minting a second id for the same mutant.
|
|
187
|
+
name_decls = Hash.new { |h, k| h[k] = {} }
|
|
162
188
|
ignore_set = config.ignore.to_set
|
|
163
189
|
jobs = []
|
|
164
190
|
ignored_results = []
|
|
191
|
+
legacy_ignore_matches = {}
|
|
192
|
+
id_map = {}
|
|
165
193
|
Project.discover(config.sources, only: config.only).each do |subject|
|
|
166
194
|
source = (source_map[subject.file] ||= File.read(subject.file))
|
|
167
195
|
disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
|
|
168
196
|
mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
|
|
169
|
-
|
|
197
|
+
id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
|
|
198
|
+
decls = name_decls[[id_path, subject.qualified_name]]
|
|
199
|
+
ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
|
|
200
|
+
ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
|
|
201
|
+
legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
|
|
170
202
|
mutations.each_with_index do |mutation, i|
|
|
171
203
|
id = ids[i]
|
|
204
|
+
legacy = legacy_ids[i]
|
|
205
|
+
id_map[id] = legacy
|
|
206
|
+
# An old entry still over-matches other files even when the new id is
|
|
207
|
+
# listed too, so every old-entry match is reported for migration.
|
|
208
|
+
if ignore_set.include?(legacy)
|
|
209
|
+
(legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
|
|
210
|
+
end
|
|
172
211
|
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
173
|
-
if suppressed?(mutation.operator, line, id, disabled, ignore_set)
|
|
212
|
+
if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
|
|
174
213
|
ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
|
|
175
214
|
else
|
|
176
215
|
jobs << [subject, mutation, id]
|
|
177
216
|
end
|
|
178
217
|
end
|
|
179
218
|
end
|
|
180
|
-
[jobs, ignored_results, source_map]
|
|
219
|
+
[jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
|
|
181
220
|
end
|
|
182
221
|
|
|
183
222
|
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
@@ -189,7 +228,8 @@ module Mutineer
|
|
|
189
228
|
#
|
|
190
229
|
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
191
230
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
192
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String,String
|
|
231
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
232
|
+
# source map, and the {.collect_jobs} extras.
|
|
193
233
|
def self.execute_external(config, operator_classes)
|
|
194
234
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
195
235
|
sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
|
|
@@ -205,12 +245,12 @@ module Mutineer
|
|
|
205
245
|
# source. Heal first, then discover jobs from the clean tree.
|
|
206
246
|
FileSwap.restore_orphans(dirs)
|
|
207
247
|
|
|
208
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
248
|
+
jobs, ignored_results, source_map, extras = collect_jobs(config, operator_classes)
|
|
209
249
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
210
250
|
|
|
211
251
|
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
212
252
|
# --test set to calibrate a timeout no mutant would use (#76).
|
|
213
|
-
next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
|
|
253
|
+
next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
|
|
214
254
|
|
|
215
255
|
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
216
256
|
# outlasts the 10s in-process fork budget), and abort if it is not green.
|
|
@@ -234,7 +274,7 @@ module Mutineer
|
|
|
234
274
|
FileSwap.restore_orphans(dirs)
|
|
235
275
|
end
|
|
236
276
|
|
|
237
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
277
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
238
278
|
end
|
|
239
279
|
end
|
|
240
280
|
|
|
@@ -331,10 +371,14 @@ module Mutineer
|
|
|
331
371
|
end
|
|
332
372
|
|
|
333
373
|
# True when this mutant is suppressed: its line bears a disable-line marker
|
|
334
|
-
# (bare, or scoped to its operator), OR its
|
|
335
|
-
# list. Checked at job-build time so a suppressed mutant is
|
|
336
|
-
|
|
337
|
-
|
|
374
|
+
# (bare, or scoped to its operator), OR its new or old-format id is in the
|
|
375
|
+
# config ignore list. Checked at job-build time so a suppressed mutant is
|
|
376
|
+
# never forked.
|
|
377
|
+
#
|
|
378
|
+
# @param ids [Array<String>, String] the mutant's new id and its old-format
|
|
379
|
+
# id, or a single id (the pre-#126 call shape).
|
|
380
|
+
def self.suppressed?(operator, line, ids, disabled, ignore_set)
|
|
381
|
+
return true if Array(ids).any? { |id| ignore_set.include?(id) }
|
|
338
382
|
|
|
339
383
|
case (entry = disabled[line])
|
|
340
384
|
when :all then true
|
data/lib/mutineer/subject.rb
CHANGED
|
@@ -4,8 +4,11 @@ module Mutineer
|
|
|
4
4
|
# One discoverable method and its AST node.
|
|
5
5
|
#
|
|
6
6
|
# Location, namespace context, and the live Prism::DefNode are kept together
|
|
7
|
-
# because mutators walk the def node directly.
|
|
8
|
-
|
|
7
|
+
# because mutators walk the def node directly. `namespace` names the owner
|
|
8
|
+
# (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
|
|
9
|
+
# chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
|
|
10
|
+
# rebuild the same Module.nesting as a whole-file reload (#145).
|
|
11
|
+
Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, keyword_init: true) do
|
|
9
12
|
# Returns the fully-qualified subject name.
|
|
10
13
|
#
|
|
11
14
|
# @return [String] namespaced method name like `Billing::Invoice#total`.
|
|
@@ -13,6 +16,14 @@ module Mutineer
|
|
|
13
16
|
namespace.join("::") + (singleton ? "." : "#") + name.to_s
|
|
14
17
|
end
|
|
15
18
|
|
|
19
|
+
# Class/module chain as written in the source, for textual wrappers. Falls
|
|
20
|
+
# back to `namespace` for subjects built without one.
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<String>] names; a root-anchored element keeps its `::`.
|
|
23
|
+
def lexical_namespace
|
|
24
|
+
lexical || namespace
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
# Returns the body location for the subject, if any.
|
|
17
28
|
#
|
|
18
29
|
# @return [Prism::Location, nil] body location or nil for empty methods.
|
data/lib/mutineer/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -91,6 +91,7 @@ files:
|
|
|
91
91
|
- lib/mutineer/parser.rb
|
|
92
92
|
- lib/mutineer/progress.rb
|
|
93
93
|
- lib/mutineer/project.rb
|
|
94
|
+
- lib/mutineer/project_path.rb
|
|
94
95
|
- lib/mutineer/rails_worker_db.rb
|
|
95
96
|
- lib/mutineer/reporter.rb
|
|
96
97
|
- lib/mutineer/result.rb
|