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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b48ca68bd77d82bd83917312fe4bc2377954403e0a63c99702c9059b9dde07b
4
- data.tar.gz: 0c11eaa74d07db731902b2fe70313f71b17eded8a59f10e5652b86985c11641c
3
+ metadata.gz: e7b8a623ac7db818d33df11a7ca32e26a67db7f156061027f018debeb17ced2f
4
+ data.tar.gz: 5957fb6e57cb5a5149822818f773f3e8d3da271bce7bb0cfc4d0a8640764aa40
5
5
  SHA512:
6
- metadata.gz: 23505720f25f34b80bde51fb0a900c16a40295966ec8bd14713d22eca03ea0058a69d83b3f701672adee6a20b10f342f09d8b561d974f2a8f23d12a82ba22e46
7
- data.tar.gz: cb2b659574bf1454ef265e3723b2c906485589696316cb63d9fb81d7696e04867e9fec73ee36b8d67063c7d941f4165fd35c922cc47cbc19466ec839ff8cbc2c
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 stable mutant ids. Each survivor's
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 stable id) and any score drop. It
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, stable mutant ids,
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
@@ -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 the stable survivor id: a NEW survivor
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 stable id is absent from
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 stable survivor id.
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 (stable ids compare
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
- current_ids = current.map(&:id)
96
- baseline_ids = @survivors.map { |h| h["id"] }
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 = current.reject { |r| baseline_ids.include?(r.id) }
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 { |h| current_ids.include?(h["id"]) }
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>)] aggregate and source map.
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
@@ -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.namespace)
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>] namespace components.
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
- mod = mod.const_get(name) # const_get resolves a compact "Foo::Bar" too
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
@@ -3,16 +3,18 @@
3
3
  require "digest"
4
4
 
5
5
  module Mutineer
6
- # Content-based stable id for a mutant — NOT byte offsets. Pure function, reused
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 qualified_name (a method, not a
12
- # byte position) + operator + the normalized mutated token + an occurrence
13
- # ordinal among same-(operator, token) twins WITHIN the subject. So it survives
14
- # any edit outside the subject method — where raw start/end offsets shift on
15
- # every edit earlier in the file and would silently stop matching.
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
- Digest::SHA256.hexdigest(
33
- [subject.qualified_name, mutation.operator,
34
- normalized_token(mutation, source), occurrence].join("\x00")
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
- self.for(subject, m, source, occ)
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
  #
@@ -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 = [] # names from `module_function :a, :b` / `module_function def`
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
- names = @module_function_names.to_set
55
- @subjects.each { |s| s.singleton = true if names.include?(s.name) }
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
- @namespace_stack.push(extract_constant_name(node.constant_path))
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
- @namespace_stack.push(extract_constant_name(node.constant_path))
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
@@ -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.3",
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]] },
@@ -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 stable id. `id` is the content-based
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
  #
@@ -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]. The CLI then reports + applies the exit code;
35
- # the integration test asserts directly on the AggregateResult.
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>)] aggregate and source map.
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 stable id is computed per subject (occurrence needs the full list) and
155
- # carried on every job so the parent can reattach it after the run. Shared by
156
- # the in-process, external, and daemon backends so job selection can never drift.
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
- # @return [Array(Array, Array<Result>, Hash<String,String>)] jobs, ignored, source_map.
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
- ids = MutantId.for_subject(subject, source, mutations)
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>)] aggregate and source map.
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 stable id is in the config ignore
335
- # list. Checked at job-build time so a suppressed mutant is never forked.
336
- def self.suppressed?(operator, line, id, disabled, ignore_set)
337
- return true if ignore_set.include?(id)
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
@@ -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
- Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, keyword_init: true) do
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.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.2.0"
5
+ VERSION = "1.3.0"
6
6
  end
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.2.0
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