mutineer 1.2.0 → 1.4.0

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