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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. 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