mutineer-mongoid 2.0.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 +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "config" # for Mutineer::ConfigError
|
|
6
|
+
require_relative "project_path"
|
|
7
|
+
|
|
8
|
+
module Mutineer
|
|
9
|
+
# CI baseline/delta gating. A baseline is a prior
|
|
10
|
+
# `mutineer run --format json` document (no bespoke format to version).
|
|
11
|
+
# Diff the current run against it by survivor id: a NEW survivor
|
|
12
|
+
# (id present now, absent in the baseline) OR a score drop is a regression the
|
|
13
|
+
# CLI turns into exit 1. Pure data, stdlib `json` only, no fork, no Rails, so
|
|
14
|
+
# it is testable in isolation from a canned JSON + a hand-built AggregateResult.
|
|
15
|
+
class Baseline
|
|
16
|
+
# The verdict of diffing a current run against the baseline.
|
|
17
|
+
# new_survivors - current Result objects whose id is absent from
|
|
18
|
+
# the baseline (the regressions to name).
|
|
19
|
+
# fixed_survivors - baseline survivor hashes absent from the current run
|
|
20
|
+
# (informational, never gates). Empty when either side
|
|
21
|
+
# is diff-scoped: an out-of-scope baseline survivor was
|
|
22
|
+
# never re-tested, so absence does not mean fixed.
|
|
23
|
+
# score_drop - current score < baseline score - epsilon. nil on
|
|
24
|
+
# either side skips the check, and a diff-scoped run
|
|
25
|
+
# (`scoped: true`) never sets it (see #diff).
|
|
26
|
+
# score_comparable - the two scores share a denominator (neither side was
|
|
27
|
+
# diff-scoped and both are non-nil), so a consumer may
|
|
28
|
+
# render them side by side. False means the score-drop
|
|
29
|
+
# check was skipped, not that it passed.
|
|
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.
|
|
36
|
+
Delta = Data.define(:new_survivors, :fixed_survivors,
|
|
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
|
|
43
|
+
|
|
44
|
+
# Load a prior --format json run. Raises ConfigError (NOT exit: a data class
|
|
45
|
+
# must never kill the host) on a missing/unreadable file, unparseable JSON,
|
|
46
|
+
# or a doc that is not a baseline shape, so the CLI maps it to exit 2
|
|
47
|
+
# (usage) like every other bad-path flag.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String] baseline JSON file path.
|
|
50
|
+
# @return [Mutineer::Baseline] baseline object.
|
|
51
|
+
# @raise [Mutineer::ConfigError] when the file is missing or invalid.
|
|
52
|
+
def self.load(path)
|
|
53
|
+
doc = JSON.parse(File.read(path))
|
|
54
|
+
unless doc.is_a?(Hash) && doc["schema_version"] && doc["survivors"].is_a?(Array)
|
|
55
|
+
raise ConfigError, "not a Mutineer JSON report: #{path}"
|
|
56
|
+
end
|
|
57
|
+
# A diff-scoped report covers only that diff's mutants: used as a
|
|
58
|
+
# baseline, every survivor outside the original diff would read as a NEW
|
|
59
|
+
# regression, and its score shares no denominator with any other run.
|
|
60
|
+
# Refuse loudly (exit 2 via the CLI) rather than gate unreliably.
|
|
61
|
+
if doc.dig("summary", "scoped") == true
|
|
62
|
+
raise ConfigError, "#{path} was written by a --since run and covers only that diff's " \
|
|
63
|
+
"mutants; regenerate the baseline from a full run " \
|
|
64
|
+
"(use --no-since if .mutineer.yml sets since:)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
new(doc)
|
|
68
|
+
rescue JSON::ParserError => e
|
|
69
|
+
raise ConfigError, "invalid baseline JSON in #{path}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attr_reader :score
|
|
73
|
+
|
|
74
|
+
# Builds a baseline from a JSON document.
|
|
75
|
+
#
|
|
76
|
+
# The baseline retains the survivor document, score, and scope marker from
|
|
77
|
+
# the JSON report. A report whose `summary.scoped` is true came from a
|
|
78
|
+
# `--since` run: its score covers only changed-line mutants, so later diffs
|
|
79
|
+
# must not compare a full-run score against it.
|
|
80
|
+
#
|
|
81
|
+
# @param doc [Hash] parsed JSON document.
|
|
82
|
+
def initialize(doc)
|
|
83
|
+
@survivors = doc["survivors"] || []
|
|
84
|
+
@score = doc.dig("summary", "score")
|
|
85
|
+
# Strict literal true only: a malformed value (say the STRING "false" in a
|
|
86
|
+
# hand-edited baseline) must not silently disable the score-drop gate.
|
|
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")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Diff a current AggregateResult against this baseline by survivor id.
|
|
93
|
+
# `epsilon` tolerates float jitter on the score (default 0.0 = any drop
|
|
94
|
+
# gates).
|
|
95
|
+
#
|
|
96
|
+
# `scoped: true` marks the current run as diff-scoped (`--since`): its score
|
|
97
|
+
# is computed over only the changed-line mutants, a different denominator
|
|
98
|
+
# from a full-run baseline, so comparing the two scores manufactures false
|
|
99
|
+
# regressions. A scoped diff keeps the new-survivor gate (ids compare
|
|
100
|
+
# fine across scopes) and still reports both scores, but never sets
|
|
101
|
+
# score_drop.
|
|
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
|
+
#
|
|
114
|
+
# @param aggregate [Mutineer::AggregateResult] current results.
|
|
115
|
+
# @param epsilon [Float] score-drop tolerance.
|
|
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.
|
|
119
|
+
# @return [Mutineer::Baseline::Delta] delta summary.
|
|
120
|
+
def diff(aggregate, epsilon: 0.0, scoped: false, id_map: {}, project_root: Dir.pwd)
|
|
121
|
+
current = aggregate.surviving_mutants
|
|
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)] }
|
|
132
|
+
|
|
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
|
|
147
|
+
# Under a diff-scoped side an out-of-scope baseline survivor was never
|
|
148
|
+
# re-tested, so reporting it "fixed" would be false: empty is honest.
|
|
149
|
+
fixed = if scoped || @scoped
|
|
150
|
+
[]
|
|
151
|
+
else
|
|
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
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
current_score = aggregate.mutation_score
|
|
159
|
+
# nil-score discipline (mirrors Reporter#exit_code): a score absent on
|
|
160
|
+
# either side cannot be compared. Skip the drop check, keep the new-
|
|
161
|
+
# survivor check. Same when EITHER side is diff-scoped (the current run
|
|
162
|
+
# via `scoped:`, or the stored baseline via its `summary.scoped` marker):
|
|
163
|
+
# the denominators differ, so the scores are not comparable.
|
|
164
|
+
comparable = !scoped && !@scoped && !@score.nil? && !current_score.nil?
|
|
165
|
+
score_drop = comparable && current_score < @score - epsilon
|
|
166
|
+
|
|
167
|
+
Delta.new(new_survivors: new_survivors, fixed_survivors: fixed,
|
|
168
|
+
score_before: @score, score_after: current_score,
|
|
169
|
+
score_drop: score_drop, score_comparable: comparable,
|
|
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))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
# Maps each source file to the set of NEW-side line numbers changed since a
|
|
8
|
+
# git ref.
|
|
9
|
+
#
|
|
10
|
+
# By parsing `git diff --unified=0`, this restricts mutations to only the
|
|
11
|
+
# diff (issue #2): on a PR you care whether the changed code is tested, so
|
|
12
|
+
# mutating just those lines is fast and actionable.
|
|
13
|
+
#
|
|
14
|
+
# git is an external tool, not a gem dependency — shelling out is fine. The
|
|
15
|
+
# `parse` carries the logic; `git_diff` is injectable so it stays testable
|
|
16
|
+
# without invoking git.
|
|
17
|
+
module ChangedLines
|
|
18
|
+
# Matches unified-diff hunks and captures the new-file start/count.
|
|
19
|
+
HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Parses unified diff text into the set of NEW-side line numbers.
|
|
24
|
+
#
|
|
25
|
+
# With `--unified=0` each hunk's `+c,d` block is exactly the changed lines:
|
|
26
|
+
# `c..c+d-1`. `d` absent means 1 line; `d == 0` is a pure deletion and
|
|
27
|
+
# contributes nothing.
|
|
28
|
+
#
|
|
29
|
+
# @param diff_text [String] raw `git diff --unified=0` output.
|
|
30
|
+
# @return [Set<Integer>] changed line numbers on the new side.
|
|
31
|
+
def parse(diff_text)
|
|
32
|
+
lines = Set.new
|
|
33
|
+
diff_text.each_line do |row|
|
|
34
|
+
m = HUNK.match(row) or next
|
|
35
|
+
start = m[1].to_i
|
|
36
|
+
count = m[2].nil? ? 1 : m[2].to_i
|
|
37
|
+
next if count.zero?
|
|
38
|
+
|
|
39
|
+
lines.merge(start...(start + count))
|
|
40
|
+
end
|
|
41
|
+
lines
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds a per-file map of changed new-side lines.
|
|
45
|
+
#
|
|
46
|
+
# @param ref [String] git ref to diff against.
|
|
47
|
+
# @param files [Array<String>] source files to inspect.
|
|
48
|
+
# @param project_root [String] repository root for `git -C`.
|
|
49
|
+
# @param runner [#call] injectable diff producer.
|
|
50
|
+
# @return [Hash<String, Set<Integer>>] absolute file path to changed lines.
|
|
51
|
+
def for(ref:, files:, project_root:, runner: method(:git_diff))
|
|
52
|
+
files.each_with_object({}) do |file, acc|
|
|
53
|
+
abs = File.expand_path(file, project_root)
|
|
54
|
+
acc[abs] = parse(runner.call(ref, abs, project_root))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
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
|
+
# A Git-ignored file outside the index contributes no changed lines, with a
|
|
63
|
+
# warning, so the skip does not look like a file with no changes.
|
|
64
|
+
# Paths are literal, so a name such as `file[1].rb` is not read as a glob.
|
|
65
|
+
#
|
|
66
|
+
# A failure is warned, never silent: an empty result means "no changed
|
|
67
|
+
# lines", which under `--since` removes every mutant for the file — a green
|
|
68
|
+
# gate must not be manufactured by a broken diff without a trace.
|
|
69
|
+
#
|
|
70
|
+
# @param ref [String] git ref to diff against.
|
|
71
|
+
# @param abs_file [String] absolute path of the file being diffed.
|
|
72
|
+
# @param project_root [String] repository root for `git -C`.
|
|
73
|
+
# @return [String] diff text, or `""` on failure (after a stderr warning).
|
|
74
|
+
def git_diff(ref, abs_file, project_root)
|
|
75
|
+
out, _err, status = Open3.capture3(
|
|
76
|
+
"git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
|
|
77
|
+
)
|
|
78
|
+
if status.success?
|
|
79
|
+
return out if indexed?(abs_file, project_root)
|
|
80
|
+
return new_file_diff(abs_file) unless ignored?(abs_file, project_root)
|
|
81
|
+
|
|
82
|
+
warn "[mutineer] #{abs_file} is ignored by Git; its lines will not be mutated (--since)"
|
|
83
|
+
return ""
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
|
|
87
|
+
""
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
|
|
90
|
+
""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Tells whether the index holds the file. Only then is git's own diff the
|
|
94
|
+
# truth; the diff text cannot tell, because a mode-only diff must stay empty.
|
|
95
|
+
#
|
|
96
|
+
# @param abs_file [String] absolute path of the file being diffed.
|
|
97
|
+
# @param project_root [String] repository root for `git -C`.
|
|
98
|
+
# @return [Boolean] true when the index holds the file.
|
|
99
|
+
def indexed?(abs_file, project_root)
|
|
100
|
+
_out, _err, known = Open3.capture3(
|
|
101
|
+
"git", "--literal-pathspecs", "-C", project_root, "ls-files", "--error-unmatch", "--", abs_file
|
|
102
|
+
)
|
|
103
|
+
known.success?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Tells whether Git ignores the file (`.gitignore`, `.git/info/exclude` or
|
|
107
|
+
# the global excludes file).
|
|
108
|
+
#
|
|
109
|
+
# @param abs_file [String] absolute path of the file.
|
|
110
|
+
# @param project_root [String] repository root for `git -C`.
|
|
111
|
+
# @return [Boolean] true when Git ignores the file. False when Git cannot
|
|
112
|
+
# tell, such as for a path beyond a symlink or inside a submodule (exit
|
|
113
|
+
# 128): that file is scored in full, as a file Git does not ignore is.
|
|
114
|
+
def ignored?(abs_file, project_root)
|
|
115
|
+
_out, _err, status = Open3.capture3(
|
|
116
|
+
"git", "-C", project_root, "check-ignore", "--quiet", "--", abs_file
|
|
117
|
+
)
|
|
118
|
+
status.success?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns a diff that marks a file as entirely new. It returns `""` when
|
|
122
|
+
# the file is empty or cannot be read (after a warning). A file outside the
|
|
123
|
+
# index has every line new; read as "unchanged", `--since` would score
|
|
124
|
+
# nothing and a positive threshold would still exit 0.
|
|
125
|
+
#
|
|
126
|
+
# @param abs_file [String] absolute path of the file being diffed.
|
|
127
|
+
# @return [String] a one-hunk diff header, or `""`.
|
|
128
|
+
def new_file_diff(abs_file)
|
|
129
|
+
count = File.foreach(abs_file).count
|
|
130
|
+
count.zero? ? "" : "@@ -0,0 +1,#{count} @@\n"
|
|
131
|
+
rescue SystemCallError => e
|
|
132
|
+
warn "[mutineer] cannot read #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
|
|
133
|
+
""
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Silences stdout in a forked child that runs tests. Every fork boundary
|
|
5
|
+
# calls {.silence} once, right after `fork`, so the test runners themselves
|
|
6
|
+
# do not touch stdout. The parent reads each child's result from a separate
|
|
7
|
+
# pipe or from the exit status, never from stdout.
|
|
8
|
+
#
|
|
9
|
+
# Stderr stays open: it carries mutineer's own diagnostics from the child.
|
|
10
|
+
#
|
|
11
|
+
# Stdlib-only, so the app-side daemon can load it.
|
|
12
|
+
module ChildStdout
|
|
13
|
+
# Points fd 1 at File::NULL and makes `$stdout` the real STDOUT again.
|
|
14
|
+
#
|
|
15
|
+
# The reopen goes through STDOUT, not `$stdout`: the parent may have left
|
|
16
|
+
# `$stdout` as a StringIO, which cannot reopen. A test that calls
|
|
17
|
+
# `$stdout.reopen` (Minitest's `capture_subprocess_io`, RSpec's
|
|
18
|
+
# `to_stdout_from_any_process`) then gets a real IO. Child processes of the
|
|
19
|
+
# test inherit the silenced fd 1 too.
|
|
20
|
+
#
|
|
21
|
+
# The reopen takes an open IO, not a path. A path reopen checks the access
|
|
22
|
+
# mode of STDOUT and raises ArgumentError when a parent left STDOUT on a
|
|
23
|
+
# file in another mode (for example the "w+x" Tempfile of Minitest's
|
|
24
|
+
# `capture_subprocess_io`).
|
|
25
|
+
#
|
|
26
|
+
# Call it only in a child that exits after the tests run. Nothing restores
|
|
27
|
+
# the previous stdout.
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
def self.silence
|
|
31
|
+
File.open(File::NULL, "w") { |null| STDOUT.reopen(null) }
|
|
32
|
+
$stdout = STDOUT
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|