saboteur 0.7.0 → 0.8.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. metadata +35 -2
@@ -17,28 +17,57 @@ module Saboteur
17
17
  # to every test in the file of the next test that starts. Each test's
18
18
  # duration is recorded so selected tests can run fastest first.
19
19
  class CoverageMap
20
- FORMAT = 8
20
+ FORMAT = 11
21
21
 
22
22
  RAN_AT_LOAD = "ran at load: the code ran while the app loaded; a forked test cannot observe a change here"
23
23
 
24
- attr_reader :lines, :tests, :build_seconds, :durations, :methods, :load_lines, :branches
24
+ attr_reader :lines, :tests, :build_seconds, :loaded_files, :durations, :methods, :load_lines, :load_methods,
25
+ :branches
25
26
 
26
27
  # lines: { "/abs/file.rb" => { 12 => ["id1", "id2"] } }
27
28
  # methods: { "/abs/file.rb" => { 12 => ["id1", "id2"] } } keyed by the
28
29
  # def's first line; line coverage never counts an endless def's body,
29
30
  # so methods carry those subjects (issue #107).
30
31
  # durations: { "id1" => seconds }
31
- def initialize(lines:, tests:, build_seconds: nil, durations: {}, methods: {}, load_lines: {}, branches: {})
32
+ def initialize(lines:, tests:, build_seconds: nil, durations: {}, methods: {}, load_lines: {}, load_methods: {},
33
+ branches: {}, loaded_files: nil)
32
34
  @lines = lines
33
35
  @tests = tests
34
36
  @build_seconds = build_seconds
35
37
  @durations = durations
36
38
  @methods = methods
37
39
  @load_lines = load_lines
40
+ @load_methods = load_methods
38
41
  @branches = branches
42
+ @loaded_files = loaded_files || []
43
+ end
44
+
45
+ # Source files the given tests touched, sorted and deduped: the
46
+ # dependency set a cached verdict must watch (#197). The inversion is
47
+ # built once per map and memoized.
48
+ def files_for(test_ids)
49
+ by_test = @files_by_test ||= invert_coverage
50
+ test_ids.flat_map { |id| by_test.fetch(id, []) }.uniq.sort
51
+ end
52
+
53
+ # { test id => [source files it touched] }, built once per map. Line
54
+ # and method coverage both count: an endless def's file has only
55
+ # method evidence (#107), and a change there must still invalidate (#344).
56
+ def invert_coverage
57
+ [lines, methods].each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |hits, acc|
58
+ hits.each { |file, line_tests| line_tests.each_value { |ids| ids.each { |id| acc[id] << file } } }
59
+ end
60
+ end
61
+
62
+ # Files that ran while the app loaded: their state precedes every
63
+ # test, so every cached verdict depends on them (#344).
64
+ def boot_files
65
+ load_lines.keys.sort
39
66
  end
40
67
 
41
68
  def tests_for(subject)
69
+ return tests_for_constant(subject) if subject.is_a?(ConstantSubject)
70
+
42
71
  tests = tests_for_lines(subject.file, subject.coverage_lines)
43
72
  return tests unless tests.empty?
44
73
 
@@ -50,10 +79,33 @@ module Saboteur
50
79
  sort_test_ids(ids)
51
80
  end
52
81
 
82
+ # A constant is observable wherever it is read: the tests that ran
83
+ # any read of it, across every file the consumer scan found (#306).
84
+ # A read inside an endless def has no line coverage, so its def's
85
+ # per-test method-call counter stands in (#319).
86
+ def tests_for_constant(subject)
87
+ ids = subject.consumer_lines.flat_map { |file, lines| tests_for_lines(file, lines) }
88
+ ids += subject.consumer_defs.flat_map do |file, def_lines|
89
+ by_method = methods[CoverageMap.canonical(file)]
90
+ by_method ? def_lines.flat_map { |line| by_method.fetch(line, []) } : []
91
+ end
92
+ sort_test_ids(ids.uniq)
93
+ end
94
+
53
95
  # Attach the parent's boot counters without losing the child's method coverage.
54
- def with_load_lines(load_lines)
96
+ def with_load_lines(load_lines, load_methods = {})
55
97
  self.class.new(lines: lines, tests: tests, build_seconds: build_seconds, durations: durations,
56
- methods: methods, load_lines: load_lines, branches: branches)
98
+ methods: methods, load_lines: load_lines, load_methods: load_methods, branches: branches,
99
+ loaded_files: loaded_files)
100
+ end
101
+
102
+ # A def the boot called (a def-time macro's class-body caller, say)
103
+ # and no test or hook called again: re-evaluating it post-boot
104
+ # changes nothing a selected test can observe, because closure
105
+ # consumers captured the pristine definition (#409).
106
+ def called_only_at_load?(file, line)
107
+ file = CoverageMap.canonical(file)
108
+ load_methods.fetch(file, []).include?(line) && methods.fetch(file, {}).fetch(line, []).empty?
57
109
  end
58
110
 
59
111
  # A requested line ran at load but no test later hit it.
@@ -69,6 +121,11 @@ module Saboteur
69
121
  # "no coverage" skip, but a subject whose lines never record at all
70
122
  # (an endless def) falls back to its method coverage (issue #107).
71
123
  def tests_for_edit(subject, line_numbers)
124
+ # An edit to a constant literal is observable at every read of the
125
+ # constant, not on the literal's own (load-time) lines: narrowing
126
+ # by edit lines is meaningless (#306).
127
+ return tests_for(subject) if subject.is_a?(ConstantSubject)
128
+
72
129
  tests = tests_for_lines(subject.file, line_numbers)
73
130
  return tests unless tests.empty?
74
131
  return [] unless tests_for_lines(subject.file, subject.coverage_lines).empty?
@@ -100,6 +157,17 @@ module Saboteur
100
157
  [lines, methods, load_lines].all? { |hits| hits.none? { |_file, values| values.any? } }
101
158
  end
102
159
 
160
+ # True when none of these files recorded a line hit, a method call, or
161
+ # a load-time hit. The recorder's map also holds the suite's spec
162
+ # files, so whole-map emptiness cannot tell a build that recorded test
163
+ # code only while the included files stayed dark.
164
+ def empty_for?(files)
165
+ wanted = files.map { |file| CoverageMap.canonical(file) }
166
+ [lines, methods, load_lines].all? do |hits|
167
+ wanted.none? { |file| hits[file]&.any? }
168
+ end
169
+ end
170
+
103
171
  # Tests that executed any of the lines, fastest first (suite order breaks
104
172
  # ties), no duplicates. With fail-fast, a mutant is killed by its
105
173
  # cheapest killing test.
@@ -124,15 +192,19 @@ module Saboteur
124
192
  hits.transform_values { |by_line| by_line.to_h { |line, ids| [line.to_s, ids.map { |id| index.fetch(id) }] } }
125
193
  end
126
194
  JSON.generate(format: FORMAT, tests: tests, build_seconds: build_seconds, lines: encode.call(lines),
127
- methods: encode.call(methods), load_lines: load_lines,
195
+ methods: encode.call(methods), load_lines: load_lines, load_methods: load_methods,
128
196
  branches: encode_branches(index),
129
- durations: tests.map { |id| durations[id]&.round(6) })
197
+ durations: tests.map { |id| durations[id]&.round(6) }, loaded_files: loaded_files)
130
198
  end
131
199
 
132
200
  def self.from_json(json)
133
201
  data = JSON.parse(json)
134
202
  raise Error, "coverage map format #{data['format']} is not #{FORMAT}" unless data["format"] == FORMAT
135
203
 
204
+ from_data(data)
205
+ end
206
+
207
+ def self.from_data(data)
136
208
  tests = data["tests"]
137
209
  decode = lambda do |hits|
138
210
  hits.transform_values do |by_line|
@@ -140,10 +212,14 @@ module Saboteur
140
212
  end
141
213
  end
142
214
  durations = tests.zip(data["durations"]).to_h.compact
143
- load_lines = (data["load_lines"] || {}).transform_values { |list| list.map(&:to_i) }
144
215
  new(lines: decode.call(data["lines"]), tests: tests, build_seconds: data["build_seconds"],
145
- durations: durations, methods: decode.call(data["methods"] || {}), load_lines: load_lines,
146
- branches: decode_branches(data.fetch("branches", {}), tests))
216
+ durations: durations, methods: decode.call(data["methods"] || {}),
217
+ load_lines: int_lists(data, "load_lines"), load_methods: int_lists(data, "load_methods"),
218
+ branches: decode_branches(data.fetch("branches", {}), tests), loaded_files: data["loaded_files"])
219
+ end
220
+
221
+ def self.int_lists(data, key)
222
+ (data[key] || {}).transform_values { |list| list.map(&:to_i) }
147
223
  end
148
224
 
149
225
  def self.decode_branches(branches, tests)
@@ -168,6 +244,25 @@ module Saboteur
168
244
  File.expand_path(path)
169
245
  end
170
246
 
247
+ # Every spelling the root can have in $LOADED_FEATURES: a symlink on
248
+ # the root path (macOS /var -> /private/var) gives the same directory
249
+ # both the resolved and the spelled form, and canonical() resolves a
250
+ # loaded feature only while its file exists. Match membership against
251
+ # both, each with a trailing slash so prefix siblings stay out.
252
+ def self.root_prefixes(root)
253
+ [File.realpath(root), File.expand_path(root)].uniq.map { |path| "#{path}/" }
254
+ end
255
+
256
+ # The dependency universe a cached verdict watches when coverage
257
+ # cannot speak: every project file the booted process loaded, plus
258
+ # the run's own files (#344).
259
+ def self.loaded_project_files(files, prefixes:)
260
+ $LOADED_FEATURES.filter_map do |file|
261
+ path = Text.utf8_strict(canonical(file))
262
+ path if path.end_with?(".rb") && prefixes.any? { |prefix| path.start_with?(prefix) }
263
+ end | files
264
+ end
265
+
171
266
  def self.load(path)
172
267
  from_json(Text.utf8_strict(File.read(path))) if File.file?(path)
173
268
  rescue JSON::ParserError, Error
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Selection
6
+ # Dependency trees are never changed source (#504): a vendored bundle
7
+ # or node_modules, tracked or untracked, git-ignored or not, is
8
+ # rejected at enumeration, so --since never lists, reads or warns on
9
+ # it. The project-local bundler path (.bundle/config's BUNDLE_PATH)
10
+ # joins the shield when set - the standard CI layout vendor/bundle is
11
+ # already under vendor/. The config file itself is project
12
+ # configuration, not vendored source, and stays visible.
13
+ module DependencyShield
14
+ TREES = %w[vendor/ node_modules/].freeze
15
+ BUNDLER_CONFIG = ".bundle/config"
16
+
17
+ module_function
18
+
19
+ def reject_trees(paths, root)
20
+ bundler = bundler_path(root)
21
+ paths.reject do |path|
22
+ next false if path == BUNDLER_CONFIG
23
+
24
+ TREES.any? { |tree| path.start_with?(tree) } || (bundler && path.start_with?(bundler))
25
+ end
26
+ end
27
+
28
+ # .bundle/config's BUNDLE_PATH as a relative directory prefix, or
29
+ # nil when no project-local config sets one (an absolute path is the
30
+ # system bundle, outside the tree, so nothing to shield).
31
+ def bundler_path(root)
32
+ config = File.join(root, ".bundle", "config")
33
+ return unless File.file?(config)
34
+
35
+ line = File.foreach(config).find { |entry| entry.start_with?("BUNDLE_PATH:") }
36
+ return unless line
37
+
38
+ value = scalar(line.split(":", 2).last.strip)
39
+ return if value.empty?
40
+
41
+ # BUNDLE_PATH: . (or ./) is the project root itself - the empty
42
+ # prefix would match every path and shield the whole tree (#580) -
43
+ # and anything outside the root is the system bundle, not project
44
+ # source. The prefix is the normalized path, not the raw scalar:
45
+ # ./deps/../deps shields deps/.
46
+ base = File.expand_path(root)
47
+ expanded = File.expand_path(value, base)
48
+ return if expanded == base || !expanded.start_with?("#{base}/")
49
+
50
+ "#{expanded.delete_prefix("#{base}/")}/"
51
+ # ArgumentError too: a NUL byte in the value makes File.expand_path
52
+ # raise it, and a config that cannot be read means no shield.
53
+ rescue SystemCallError, ArgumentError
54
+ nil
55
+ end
56
+
57
+ # The scalar after "BUNDLE_PATH:": bundler writes a bare word, but
58
+ # YAML also allows it quoted - surrounding matching quotes are not
59
+ # part of the path.
60
+ def scalar(raw)
61
+ quote = raw[0]
62
+ value = if raw.length >= 2 && raw[-1] == quote && ["'", '"'].include?(quote)
63
+ raw[1..-2]
64
+ else
65
+ raw
66
+ end
67
+ strip_comment(value)
68
+ end
69
+
70
+ # Bundler::YAMLSerializer#strip_comment, which parses .bundle/config
71
+ # for Bundler itself: a # after the first character starts a comment
72
+ # even inside quotes, while a leading # is data.
73
+ def strip_comment(value)
74
+ return value unless value.include?("#") && !value.start_with?("#")
75
+
76
+ value.split("#", 2).first.strip
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,27 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Selection
6
+ # The --test-command mode has no coverage map (#140): the command runs
7
+ # the whole suite per mutant, so every subject reads as covered by the
8
+ # one pseudo-test and no recorded timing exists, leaving the per-mutant
9
+ # timeout to the config's baseline_timeout.
10
+ class ExternalTestMap
11
+ def tests = [Execution::ExternalRunner::PSEUDO_TEST]
12
+
13
+ def tests_for(_subject) = tests
14
+
15
+ def tests_for_edit(_subject, _lines) = tests
16
+
17
+ def tests_for_branches(_subject, _edits) = tests
18
+
19
+ def ran_at_load?(_file, _lines) = false
20
+
21
+ # No boot happens in this mode, so nothing can have run only at load.
22
+ def called_only_at_load?(_file, _line) = false
23
+
24
+ def recorded_runtime(_tests) = nil
25
+ end
26
+ end
27
+ end
@@ -3,6 +3,8 @@
3
3
 
4
4
  require "open3"
5
5
  require_relative "../text"
6
+ require_relative "dependency_shield"
7
+ require_relative "layout_only_lines"
6
8
  require_relative "untracked_changes"
7
9
 
8
10
  module Saboteur
@@ -11,7 +13,7 @@ module Saboteur
11
13
  # changes, as { "/abs/path.rb" => Set[line numbers in the current file] }.
12
14
  module GitDiff
13
15
  NO_FILE = "/dev/null" # rubocop:disable Style/FileNull -- literal in git diff output
14
- HUNK = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
16
+ HUNK = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
15
17
 
16
18
  module_function
17
19
 
@@ -41,7 +43,61 @@ module Saboteur
41
43
  status.success? ? out.b : nil
42
44
  end
43
45
 
46
+ # Paths under ROOT, relative to it, that differ between the merge base
47
+ # of REF and the working tree: { present: [...], deleted: [...] }.
48
+ # present holds added, modified and untracked files; deleted holds
49
+ # files gone from the working tree. A rename counts as a deletion of
50
+ # the old path and an addition of the new one.
51
+ def changed_paths(ref, root:)
52
+ verify_ref!(ref, root: root)
53
+ base = merge_base(ref, root: root)
54
+ diff = %w[diff -z --name-only --relative --no-renames]
55
+ {
56
+ # An untracked file is never in the diff, so the two lists are disjoint.
57
+ present: DependencyShield.reject_trees(names(root, *diff, "--diff-filter=d", base, "--") +
58
+ names(root, "ls-files", "-z", "--others",
59
+ "--exclude-standard"), root).sort,
60
+ deleted: DependencyShield.reject_trees(names(root, *diff, "--diff-filter=D", base, "--"),
61
+ root).sort
62
+ }
63
+ end
64
+
65
+ def names(root, *args)
66
+ git(root, *args).b.split("\0").map { |name| Text.utf8_identity(name) }
67
+ end
68
+
69
+ # The two line maps of a --since diff (#313). changed holds every line
70
+ # the text diff touches: subject admission and the formatting-only
71
+ # skip read it, so a method that was only re-indented is still found
72
+ # and reported as unchanged. behavior drops the layout-only lines:
73
+ # only it narrows a changed method's fine-grained mutants.
74
+ LineMaps = Data.define(:changed, :behavior)
75
+
44
76
  def changed_lines(ref, root:, include: nil, diagnostics: nil)
77
+ line_maps(ref, root: root, include: include, diagnostics: diagnostics).changed
78
+ end
79
+
80
+ # Tracked files whose diff deletes lines without adding any, as
81
+ # canonical absolute paths (realpath'd toplevel joined to each diff
82
+ # path), so callers compare in the same space as
83
+ # CoverageMap.canonical keys whatever the project-root layout: a
84
+ # monorepo root below the git toplevel must not silently disable the
85
+ # exclusion (#484). A pure deletion adds no new code to mutate - the
86
+ # change itself is what the suite covers - so the file yields no
87
+ # subject and no fine-grained mutant, and a spec-less such file must
88
+ # not stop the run. The neighbor lines a pure deletion touches
89
+ # (touched_lines) are context, not change. Untracked files are
90
+ # all-additions and never listed.
91
+ def deletion_only_files(ref, root:) # rubocop:disable Metrics/ModuleLength -- one focused addition to an exactly-full module
92
+ top = File.realpath(Text.utf8_identity(git(root, "rev-parse", "--show-toplevel").b.strip))
93
+ diff = git(root, "-c", "core.quotePath=false", "diff", "--unified=0", "--no-color", "--no-ext-diff",
94
+ merge_base(ref, root: root), "--").b
95
+ added = Hash.new { |hash, file| hash[file] = false }
96
+ each_hunk(diff, top) { |file, (_, _, _, count)| added[file] ||= count.positive? }
97
+ added.reject { |_, yes| yes }.keys
98
+ end
99
+
100
+ def line_maps(ref, root:, include: nil, diagnostics: nil)
45
101
  verify_ref!(ref, root: root)
46
102
  base = merge_base(ref, root: root)
47
103
  # Root and paths are filesystem identity: strip as bytes and retag
@@ -49,14 +105,30 @@ module Saboteur
49
105
  # would rename a raw-byte path). Parse the diff body as bytes and
50
106
  # tag each extracted path UTF-8 at the boundary.
51
107
  top = Text.utf8_identity(git(root, "rev-parse", "--show-toplevel").b.strip)
52
- changes = parse(git(root, "-c", "core.quotePath=false", "diff", "--unified=0", "--no-color",
53
- "--no-ext-diff", base, "--").b, top)
108
+ diff = %w[-c core.quotePath=false diff --unified=0 --no-color --no-ext-diff]
109
+ changes = parse(git(root, *diff, base, "--").b, top)
54
110
  UntrackedChanges.union(changes, top, include: include, diagnostics: diagnostics)
55
- changes
111
+ behavior = drop_layout_only_lines(changes, git(root, *diff, "--ignore-space-change", base, "--").b, base,
112
+ top, include: include)
113
+ LineMaps.new(changed: changes, behavior: behavior)
56
114
  end
57
115
 
58
116
  def parse(diff, top)
59
117
  changes = Hash.new { |hash, file| hash[file] = Set.new }
118
+ each_hunk(diff, top) { |file, (_, _, start, count)| changes[file].merge(touched_lines(start, count)) }
119
+ changes.default_proc = nil
120
+ changes
121
+ end
122
+
123
+ # The new-side lines a hunk touches. A pure deletion touches the lines
124
+ # on either side of it.
125
+ def touched_lines(start, count)
126
+ count.zero? ? [start, start + 1] : (start...(start + count)).to_a
127
+ end
128
+
129
+ # Each hunk header of a zero-context diff as the absolute path of the
130
+ # file and [old_start, old_count, new_start, new_count].
131
+ def each_hunk(diff, top)
60
132
  file = nil
61
133
  diff.each_line do |line|
62
134
  if line.start_with?("+++ ")
@@ -65,15 +137,25 @@ module Saboteur
65
137
  # HUNK is anchored, so a non-hunk line - including a raw-byte
66
138
  # content line - fails the match at its first byte.
67
139
  elsif file && (match = HUNK.match(line))
68
- start = match[1].to_i
69
- count = (match[2] || "1").to_i
70
- # A pure deletion touches the lines on either side of it.
71
- lines = count.zero? ? [start, start + 1] : (start...(start + count)).to_a
72
- changes[file].merge(lines)
140
+ old_start, old_count, new_start, new_count = match.captures
141
+ yield file, [old_start.to_i, (old_count || "1").to_i, new_start.to_i, (new_count || "1").to_i]
73
142
  end
74
143
  end
75
- changes.default_proc = nil
76
- changes
144
+ end
145
+
146
+ # Only files the run could mutate are lexed; a file new at the base
147
+ # has nothing to compare with and keeps every line.
148
+ def drop_layout_only_lines(changes, spaced_diff, base, top, include:)
149
+ hunks = Hash.new { |hash, file| hash[file] = [] }
150
+ each_hunk(spaced_diff, top) { |file, hunk| hunks[file] << hunk }
151
+ changes.to_h do |file, lines|
152
+ mutable = include.nil? || include.include?(file)
153
+ base_source = mutable && file_at(base, file.delete_prefix("#{top}/"), root: top)
154
+ next [file, lines] unless base_source
155
+
156
+ [file, LayoutOnlyLines.new(hunks[file], base_source: base_source, current_source: File.binread(file))
157
+ .restrict(lines)]
158
+ end
77
159
  end
78
160
 
79
161
  ESCAPES = { "n" => "\n", "t" => "\t", '"' => '"', "\\" => "\\", "a" => "\a", "b" => "\b",
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+
6
+ module Saboteur
7
+ module Selection
8
+ # Drops changed lines whose only change is layout (#313). Wrapping
9
+ # existing statements in a block re-indents them; a plain diff marks
10
+ # every one as changed, so their mutants run as if the change touched
11
+ # them.
12
+ #
13
+ # A whitespace-insensitive diff (git diff -b) finds the lines that
14
+ # differ by more than spacing and pairs every other line with its line
15
+ # at the base. A changed line outside that diff is dropped only when
16
+ # Prism lexes it to the same tokens as its base line: whitespace inside
17
+ # a string, heredoc or regexp is part of a token, so it still counts as
18
+ # a change. A `__LINE__` on a line that moved names another number, so
19
+ # that line counts too.
20
+ class LayoutOnlyLines
21
+ LINE_KEYWORD = :KEYWORD___LINE__
22
+
23
+ # hunks: [[old_start, old_count, new_start, new_count], ...] of the
24
+ # whitespace-insensitive diff of one file, in order.
25
+ def initialize(hunks, base_source:, current_source:)
26
+ @hunks = hunks
27
+ @base_tokens = tokens_by_line(base_source)
28
+ @current_tokens = tokens_by_line(current_source)
29
+ end
30
+
31
+ # The -b diff's own lines, with its deletion markers, plus every line
32
+ # of CHANGED whose tokens differ from its base line.
33
+ def restrict(changed)
34
+ spaced_lines | changed.reject { |line| same_tokens?(line) }
35
+ end
36
+
37
+ private
38
+
39
+ def spaced_lines
40
+ @hunks.each_with_object(Set.new) do |(_, _, start, count), lines|
41
+ lines.merge(GitDiff.touched_lines(start, count))
42
+ end
43
+ end
44
+
45
+ def same_tokens?(line)
46
+ base_line = base_line_of(line)
47
+ tokens = @current_tokens[line]
48
+ return false if base_line != line && tokens.any? { |type, _| type == LINE_KEYWORD }
49
+
50
+ tokens == @base_tokens[base_line]
51
+ end
52
+
53
+ # A line outside every hunk sits at its base position shifted by the
54
+ # lines the hunks above it added or removed. A line inside a hunk is
55
+ # one of the -b diff's own lines and is kept whatever this answers.
56
+ def base_line_of(line)
57
+ line - @hunks.sum { |_, old_count, start, count| start + count <= line ? count - old_count : 0 }
58
+ end
59
+
60
+ # Every token on each line it spans, as type and text. Every line has
61
+ # one (a blank line its newline, text after __END__ that token), and a
62
+ # missing line reads as [] through the default. A token that
63
+ # spans lines (a heredoc body, a multi-line string) belongs to each.
64
+ # A token's location ends after its last byte, so one that ends in a
65
+ # newline would reach into the next line without this.
66
+ def tokens_by_line(source)
67
+ Prism.lex(source).value.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(token, _), lines|
68
+ next if token.type == :EOF
69
+
70
+ token.location.start_line.upto(last_line(token)) { |line| lines[line] << [token.type, token.value] }
71
+ end
72
+ end
73
+
74
+ def last_line(token)
75
+ location = token.location
76
+ token.value.end_with?("\n") ? location.end_line - 1 : location.end_line
77
+ end
78
+ end
79
+ end
80
+ end
@@ -25,7 +25,7 @@ module Saboteur
25
25
 
26
26
  def start
27
27
  Coverage.resume if Coverage.state == :suspended
28
- Coverage.result(stop: false, clear: true)
28
+ CoverageReads.result(stop: false, clear: true)
29
29
  end
30
30
 
31
31
  def test_started(id, test_file)
@@ -44,14 +44,15 @@ module Saboteur
44
44
  end
45
45
  end
46
46
 
47
- def result(build_seconds: nil)
47
+ def result(build_seconds: nil, loaded_files: [])
48
48
  assign_outside(@outside, @lines)
49
49
  assign_outside(@outside_methods, @methods)
50
50
  assign_outside(@outside_branches, @branches)
51
51
  lines = @lines.transform_values { |by_line| by_line.transform_values(&:uniq) }
52
52
  methods = @methods.transform_values { |by_line| by_line.transform_values(&:uniq) }
53
+ branches = @branches.transform_values { |arms| arms.transform_values(&:uniq) }
53
54
  CoverageMap.new(lines: lines, tests: @tests, build_seconds: build_seconds, durations: @durations,
54
- methods: methods, branches: @branches.transform_values { |arms| arms.transform_values(&:uniq) })
55
+ methods: methods, branches: branches, loaded_files: loaded_files)
55
56
  end
56
57
 
57
58
  private
@@ -65,8 +66,15 @@ module Saboteur
65
66
 
66
67
  def record_branches(file, data)
67
68
  data.fetch(:branches, {}).each do |(_kind, _id, *parent_span), arms|
69
+ spans = arms.map { |(_arm_kind, _arm_id, *span), _count| span }
68
70
  arms.each do |(_arm_kind, _arm_id, *span), count|
69
71
  next if span == parent_span
72
+ # Ruby spans a `when X` arm with a bare `nil` body from the
73
+ # `when` to the case's end, containing the sibling arms.
74
+ # Sibling arms are disjoint by definition, so a containing span
75
+ # is bogus; recording it would let it intersect and narrow
76
+ # selection for edits inside the siblings it swallows.
77
+ next if spans.any? { |other| span != other && contains?(span, other) }
70
78
 
71
79
  @branches[file][span] # Keep zero-hit arms as evidence, not as covered code.
72
80
  yield span if count.positive?
@@ -74,6 +82,16 @@ module Saboteur
74
82
  end
75
83
  end
76
84
 
85
+ # [line, column] pairs compare lexically, like CoverageMap#position.
86
+ # Both endpoints come from one slice per side: a separate slice per
87
+ # comparand would make its size irrelevant on the right-hand side of
88
+ # `<=>` (a prefix decides first), hiding an equivalent mutant.
89
+ def contains?(outer, inner)
90
+ outer_start, inner_start = [outer, inner].map { |span| span.first(2) }
91
+ inner_end, outer_end = [inner, outer].map { |span| span.last(2) }
92
+ (outer_start <=> inner_start) <= 0 && (inner_end <=> outer_end) <= 0
93
+ end
94
+
77
95
  def hit_table
78
96
  Hash.new { |hash, file| hash[file] = Hash.new { |by_line, line| by_line[line] = [] } }
79
97
  end
@@ -89,7 +107,7 @@ module Saboteur
89
107
  # Coverage is suspended while the delta is processed, so the recorder's
90
108
  # own work (and anything it calls) is never attributed to a test.
91
109
  def each_hit
92
- snapshot = Coverage.result(stop: false, clear: true)
110
+ snapshot = CoverageReads.result(stop: false, clear: true)
93
111
  suspended = Coverage.running?
94
112
  Coverage.suspend if suspended
95
113
  snapshot.each do |path, data|
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Selection
6
+ # Positional source-scope arguments from the command line.
7
+ module SourceScope
8
+ module_function
9
+
10
+ # "One --test, several paths" lands the extra paths in the source
11
+ # scope; specs are never subjects, so the run would mutate nothing
12
+ # and still exit 0. Refuse them as a usage error before any boot.
13
+ def verify_no_specs!(paths)
14
+ spec_files = paths.select { |path| path.end_with?("_spec.rb") }
15
+ return if spec_files.empty?
16
+
17
+ raise ConfigError,
18
+ "spec files are not mutated - pass them with --test, one path per use: #{spec_files.join(', ')}"
19
+ end
20
+ end
21
+ end
22
+ end