saboteur 0.5.0 → 0.7.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -2
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # The --max-duration wall-clock budget (#245). The clock anchors at
7
+ # engine entry, so boot, baselines and the coverage map spend it too;
8
+ # schedulers get an absolute deadline so a spent budget also stops a
9
+ # long subject between mutants.
10
+ module RunBudget
11
+ private
12
+
13
+ def start_budget_clock(started)
14
+ @run_started = started
15
+ end
16
+
17
+ # The whole run is budgeted, baselines included: an oversized
18
+ # baseline spends the budget by itself.
19
+ def budget_exceeded?
20
+ return false unless config["max_duration"]
21
+
22
+ exceeded = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started >= config["max_duration"]
23
+ announce_budget_once if exceeded
24
+ exceeded
25
+ end
26
+
27
+ # A phase admitted inside the budget still gets only the time left
28
+ # in it: baseline passes and coverage builds cap at the deadline.
29
+ def budget_capped_timeout(timeout)
30
+ deadline = budget_deadline
31
+ return timeout unless deadline
32
+
33
+ (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).clamp(0.0, timeout)
34
+ end
35
+
36
+ # Absolute monotonic deadline shared with the schedulers (forked
37
+ # workers inherit it): a subject's remaining mutants stop once it
38
+ # passes, so the overshoot after a spent budget stays under one
39
+ # mutant deadline.
40
+ def budget_deadline
41
+ config["max_duration"] && (@run_started + config["max_duration"])
42
+ end
43
+
44
+ # Boot and baselines can spend the budget by themselves: stop before
45
+ # an expensive cold coverage build and mark every selected subject
46
+ # unrun.
47
+ def abandon_for_budget?(subjects)
48
+ return false unless budget_exceeded?
49
+
50
+ @early_stop = EarlyStop.new(reason: "max_duration", unreached: subjects.map(&:id), truncated: [],
51
+ unverified: [])
52
+ true
53
+ end
54
+
55
+ def announce_budget_once
56
+ return if @budget_announced
57
+
58
+ @budget_announced = true
59
+ @diagnostics.inform("--max-duration reached: stopping before the next subject; the report marks it partial")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "json"
6
+ require_relative "../text"
6
7
  require_relative "data_inputs"
7
8
 
8
9
  module Saboteur
@@ -127,7 +128,7 @@ module Saboteur
127
128
  issues = output_overlap_issues
128
129
  entries = inventory(issues).sort_by(&:path).freeze
129
130
  issues = issues.uniq.sort
130
- settings_digest = hash_value(@config.to_h.except(*LOCATION_SETTINGS))
131
+ settings_digest = hash_value(@config.selection_settings.except(*LOCATION_SETTINGS))
131
132
  runtime_digest = hash_value(@runtime_identity)
132
133
  digest = hash_value(format: FORMAT_VERSION, files: entries.map(&:to_h),
133
134
  settings: settings_digest, runtime: runtime_digest)
@@ -252,6 +253,9 @@ module Saboteur
252
253
  # undeclared dependency boundary, not a silently omitted source tree.
253
254
  def project_files(issues, directory = @root)
254
255
  Dir.children(directory).sort.flat_map do |name|
256
+ # Child names arrive locale-tagged; retag losslessly before
257
+ # joining with the UTF-8 root (#231).
258
+ name = Text.utf8_identity(name)
255
259
  path = File.join(directory, name)
256
260
  next [] if excluded?(path, name)
257
261
 
@@ -16,7 +16,10 @@ module Saboteur
16
16
  end
17
17
 
18
18
  # The third C1 layer (#152): each mutant child makes the project tree
19
- # read-only except tmp/, the cache dir and the system temp dir. The
19
+ # read-only except tmp/ and the system temp dir - the cache dir stays
20
+ # read-only too, so a mutant cannot delete the baseline, the reports
21
+ # or other children's logs; its own log arrives as an inherited open
22
+ # descriptor (#234). The
20
23
  # claim needs Landlock ABI 3 (Linux 6.2+): earlier ABIs cannot restrict
21
24
  # truncate(), so the run degrades honestly - children run with the
22
25
  # guard and canary only, noted once - unless c.sandbox = "strict",
@@ -28,14 +31,15 @@ module Saboteur
28
31
  return restrict_hook if abi >= 3
29
32
 
30
33
  if @config["sandbox"] == "strict"
31
- raise Error, "sandbox is strict but #{sandbox_gap(abi)}; refusing to run without the promised " \
34
+ raise Error, "sandbox is strict but #{self.class.sandbox_gap(abi)}; refusing to run without the promised " \
32
35
  "sandbox - set c.sandbox = true to run degraded, or false to opt out"
33
36
  end
34
37
  # Once, through the report's warnings only: warn + inform printed the
35
38
  # same line twice (D's review, #178).
36
39
  unless @sandbox_note_given
37
40
  @sandbox_note_given = true
38
- @diagnostics.warn("sandbox is on but #{sandbox_gap(abi)}; children run with the hazard guard and canary only")
41
+ @diagnostics.warn("sandbox is on but #{self.class.sandbox_gap(abi)}; children run with the hazard guard " \
42
+ "and canary only")
39
43
  end
40
44
  nil
41
45
  end
@@ -49,7 +53,7 @@ module Saboteur
49
53
  "unsandboxed - set c.sandbox = false to proceed without it"
50
54
  end
51
55
 
52
- def sandbox_gap(abi)
56
+ def self.sandbox_gap(abi)
53
57
  return "this kernel lacks Landlock (needs Linux 6.2+)" if abi.zero?
54
58
 
55
59
  "this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
@@ -62,20 +66,23 @@ module Saboteur
62
66
 
63
67
  # c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
64
68
  # ...): without a grant, writes there fail with EACCES in the child.
69
+ # Only the user grants are created: the project tmp is granted when it
70
+ # already exists, never made for it (#236).
65
71
  def sandbox_writable_dirs
66
72
  user_dirs = @config["sandbox_writable"].map { |dir| File.expand_path(dir, @root) }
67
- dirs = [File.join(@root, "tmp"), @config.path("cache_dir")] + user_dirs
68
- dirs.each { |dir| FileUtils.mkdir_p(dir) }
73
+ user_dirs.each { |dir| FileUtils.mkdir_p(dir) }
74
+ tmp = File.join(@root, "tmp")
75
+ dirs = Dir.exist?(tmp) ? [tmp] + user_dirs : user_dirs
69
76
  real = (dirs.map { |dir| File.realpath(dir) } + [File.realpath(Dir.tmpdir)]).uniq
70
77
  check_sandbox_write_set(real, user_dirs.map { |dir| File.realpath(dir) })
71
78
  real
72
79
  end
73
80
 
74
- # The effective write set - every grant realpath'd, cache_dir, the
75
- # project tmp and the system temp dir included - must not contain the
76
- # project root: cache_dir = ".", a tmp symlink or a checkout inside the
77
- # system temp dir would otherwise leave the whole tree writable without
78
- # a word. c.sandbox = "strict" refuses: strict guards the effective
81
+ # The effective write set - every grant realpath'd, the project tmp
82
+ # and the system temp dir included - must not contain the project
83
+ # root: a tmp symlink or a checkout inside the system temp dir would
84
+ # otherwise leave the whole tree writable without a word.
85
+ # c.sandbox = "strict" refuses: strict guards the effective
79
86
  # policy, not only the kernel ABI. A user grant containing another
80
87
  # grant only makes the narrower one redundant, so that stays a warning.
81
88
  def check_sandbox_write_set(all_dirs, user_dirs)
@@ -27,8 +27,9 @@ module Saboteur
27
27
  tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
28
28
  end
29
29
 
30
- def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil)
30
+ def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil)
31
31
  @config = config
32
+ @deadline = deadline
32
33
  @runner = runner
33
34
  @map = map
34
35
  @reevaluator = reevaluator
@@ -73,23 +74,53 @@ module Saboteur
73
74
 
74
75
  # Verdicts that did not hold up are warned about once per subject;
75
76
  # only checked verdicts are stored for --incremental.
77
+ # A spent budget admits no check: forking the unchanged run with no
78
+ # time left would turn genuine kills into fabricated errors and cache
79
+ # them. Fresh kills stand unverified instead - outside the score,
80
+ # never cached - and the distribution marks the subject so the run
81
+ # reports budget-partial.
76
82
  def checked(plan, results)
83
+ return results.map { |result| unverifiable(result) } if budget_spent?
84
+
77
85
  subject = plan.subject
78
- run_unchanged = lambda do |tests, timeout|
79
- @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: timeout,
80
- eval_prefix: eval_prefix(plan))
81
- .tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
82
- end
83
- check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
86
+ check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
87
+ baseline_timeout: @config["baseline_timeout"])
84
88
  final = check.call(results)
85
- final.reject(&:reused?).each { |result| @cache&.record(result) }
86
- overturned = final.zip(results).count { |after, before| !after.equal?(before) }
89
+ # Unverified verdicts are never cached: a budget-cut check proves
90
+ # nothing, and a stored copy would read as confirmed next run.
91
+ final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
92
+ overturned = final.zip(results).count { |after, before| after.status == :error && !after.equal?(before) }
87
93
  if overturned.positive?
88
94
  @diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
89
95
  end
90
96
  final
91
97
  end
92
98
 
99
+ # Admission is per sequence, not just at checked() entry: a check
100
+ # admitted with time left can hit its capped timeout mid-check, and a
101
+ # later sequence can arrive after the budget is spent. Neither may
102
+ # fork with no time left, and neither outcome is a rejected kill -
103
+ # the check never got its answer (#296).
104
+ def unchanged_runner(subject, plan)
105
+ lambda do |tests, timeout|
106
+ if budget_spent?
107
+ Execution::Runner::Outcome.new(status: :unverified, runtime: 0.0,
108
+ message: "the --max-duration budget is spent")
109
+ else
110
+ capped = budget_capped(timeout)
111
+ outcome = @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: capped,
112
+ eval_prefix: eval_prefix(plan))
113
+ note_leftovers(subject, "verdict check", outcome)
114
+ if outcome.status == :timeout && capped < timeout
115
+ Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime,
116
+ message: "the --max-duration budget cut the verdict check short")
117
+ else
118
+ outcome
119
+ end
120
+ end
121
+ end
122
+ end
123
+
93
124
  # When every verdict can be reused, nothing forks: not even the neutral
94
125
  # check. Returns the results, or nil at the first one that must run.
95
126
  def all_reused(plan, candidates)
@@ -100,10 +131,16 @@ module Saboteur
100
131
  end
101
132
  end
102
133
 
134
+ # The budget deadline is checked between mutants, so a spent
135
+ # --max-duration stops inside a long subject too; completed results
136
+ # are kept and the distribution marks what follows as unreached.
103
137
  def run_candidates(candidates, &run_one)
104
138
  coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
105
- results = coarse.map { |mutation, text| run_one.call(mutation, text) }
106
- results + fine.map { |mutation, text| run_one.call(mutation, text) }
139
+ (coarse + fine).each_with_object([]) do |(mutation, text), results|
140
+ break results if @deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
141
+
142
+ results << run_one.call(mutation, text)
143
+ end
107
144
  end
108
145
 
109
146
  # A stored verdict that still holds, as a result; nil when it must run.
@@ -206,19 +243,78 @@ module Saboteur
206
243
  # baseline_timeout. If the deadline from that real runtime is longer,
207
244
  # the mutant runs again with it, and later mutants of the subject get
208
245
  # the measured slowdown. A timeout that remains is the mutant's own.
246
+ # A --max-duration deadline caps every admission the scheduler makes:
247
+ # verdict checks and timeout calibrations get only the time left.
248
+ def budget_capped(timeout)
249
+ return timeout unless @deadline
250
+
251
+ remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ remaining.clamp(0.0, timeout)
253
+ end
254
+
255
+ def budget_spent?
256
+ @deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
257
+ end
258
+
259
+ # A fresh kill whose check the budget skipped proves nothing yet: it
260
+ # becomes unverified, outside the score, and is never cached.
261
+ def unverifiable(result)
262
+ return result if result.reused? || result.status != :killed
263
+
264
+ result.as_unverified("verdict check skipped: the --max-duration budget is spent")
265
+ end
266
+
267
+ # A timeout the budget keeps the calibration from judging proves
268
+ # nothing either: no zero-time forks, and a retry cut short does not
269
+ # count as detected.
209
270
  def calibrate(plan, mutation, text, tests, timeout)
210
- unchanged = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
211
- timeout: @config["baseline_timeout"], eval_prefix: eval_prefix(plan))
212
- note_leftovers(plan.subject, "timeout check", unchanged)
271
+ if budget_spent?
272
+ return unverified_outcome(timeout, "timeout calibration skipped: the --max-duration budget is spent")
273
+ end
274
+
275
+ unchanged, shortened = unchanged_timing(plan, tests)
276
+ if unchanged.status == :timeout && shortened
277
+ return unverified_outcome(unchanged.runtime, "the --max-duration budget cut the timeout calibration short")
278
+ end
213
279
  return unchanged_failed(unchanged) unless unchanged.status == :survived
214
280
 
281
+ retry_longer(plan, mutation, text, tests, [timeout, unchanged.runtime])
282
+ end
283
+
284
+ # The unchanged method under the budget-capped baseline timeout, and
285
+ # whether the cap shortened it.
286
+ def unchanged_timing(plan, tests)
287
+ capped = budget_capped(@config["baseline_timeout"])
288
+ outcome = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
289
+ timeout: capped, eval_prefix: eval_prefix(plan))
290
+ note_leftovers(plan.subject, "timeout check", outcome)
291
+ [outcome, capped < @config["baseline_timeout"]]
292
+ end
293
+
294
+ def retry_longer(plan, mutation, text, tests, limit)
295
+ timeout, unchanged_runtime = limit
215
296
  recorded = @map.recorded_runtime(tests)
216
- plan.slowdown = [plan.slowdown, unchanged.runtime / recorded].max if recorded&.positive?
217
- longer = deadline(unchanged.runtime)
297
+ plan.slowdown = [plan.slowdown, unchanged_runtime / recorded].max if recorded&.positive?
298
+ longer = deadline(unchanged_runtime)
218
299
  return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
300
+ # The unchanged timing may have used the final allowance: no
301
+ # zero-time retry fork (#296).
302
+ if budget_spent?
303
+ return unverified_outcome(timeout, "the --max-duration budget is spent before the mutant retry")
304
+ end
305
+
306
+ retry_timeout = budget_capped(longer)
307
+ retried = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: retry_timeout,
308
+ mutation: mutation, eval_prefix: eval_prefix(plan))
309
+ return retried unless retried.status == :timeout && retry_timeout < longer
310
+
311
+ unverified_outcome(retried.runtime, "the --max-duration budget cut the mutant retry short")
312
+ end
219
313
 
220
- @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: longer, mutation: mutation,
221
- eval_prefix: eval_prefix(plan))
314
+ # A budget-cut calibration proves nothing about the mutant: outside
315
+ # the score, and never cached.
316
+ def unverified_outcome(runtime, message)
317
+ Execution::Runner::Outcome.new(status: :unverified, runtime: runtime, message: message)
222
318
  end
223
319
 
224
320
  def unchanged_failed(unchanged)
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../text"
5
+
4
6
  module Saboteur
5
7
  class Engine
6
8
  # The subjects to mutate: every def in the included files, narrowed by
@@ -13,11 +15,12 @@ module Saboteur
13
15
  @config = config
14
16
  @diagnostics = diagnostics
15
17
  @root = File.realpath(config.root)
16
- @changed_lines = nil
17
18
  end
18
19
 
19
20
  def select(files)
21
+ @files = files
20
22
  subjects = find(files)
23
+ @discovered = subjects
21
24
  subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
22
25
  subjects = by_change(subjects, @config["since"]) if @config["since"]
23
26
  subjects
@@ -28,6 +31,22 @@ module Saboteur
28
31
  id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
29
32
  end
30
33
 
34
+ # The files (absolute paths) holding a subject matching any pattern,
35
+ # returned relative to root. --subject inference guesses specs for
36
+ # exactly these, so one unrelated spec-less file cannot block a
37
+ # one-method run.
38
+ def self.files_matching(root, files, patterns)
39
+ finder = SubjectFinder.new
40
+ prefix = "#{root}/"
41
+ files.filter_map do |file|
42
+ parsed = finder.call(file)
43
+ matching = parsed.subjects.any? do |subject|
44
+ patterns.any? { |pattern| match?(subject.id, pattern) }
45
+ end
46
+ file.delete_prefix(prefix) if matching
47
+ end.sort
48
+ end
49
+
31
50
  private
32
51
 
33
52
  def find(files)
@@ -54,14 +73,74 @@ module Saboteur
54
73
  end
55
74
 
56
75
  def by_change(subjects, ref)
57
- @changed_lines = Selection::GitDiff.changed_lines(ref, root: @root)
76
+ @changed_lines = Selection::GitDiff.changed_lines(ref, root: @root, include: @files.to_set,
77
+ diagnostics: @diagnostics)
58
78
  .transform_keys { |file| Selection::CoverageMap.canonical(file) }
59
- changed = subjects.select do |subject|
60
- @changed_lines[subject.file]&.any? { |line| subject.contains_line?(line) }
61
- end
79
+ changed = changed_subjects(subjects)
80
+ warn_changed_files_without_subjects
81
+ warn_excluded_changed_files
62
82
  drop_unchanged_syntax(changed, ref)
63
83
  end
64
84
 
85
+ # subject.file carries the locale tag under a non-UTF-8 locale
86
+ # while the diff keys are UTF-8; retag losslessly before lookup so
87
+ # a non-ASCII path matches by its exact bytes (#231).
88
+ def changed_subjects(subjects)
89
+ subjects.select do |subject|
90
+ key = Selection::CoverageMap.canonical(Text.utf8_identity(subject.file))
91
+ lines = @changed_lines[key]
92
+ lines && subject.overlaps_lines?(lines)
93
+ end
94
+ end
95
+
96
+ # A changed Ruby file in scope whose changed lines cover no subject
97
+ # silently contributes nothing to the run (#232): warn, or a typo, an
98
+ # unparseable file or a selection bug passes as a clean selection.
99
+ # Comment-only changes stay quiet: their file matched subjects before
100
+ # drop_unchanged_syntax pruned them, and that prune is reported. A
101
+ # diff touching no included Ruby file is a legitimate empty scope.
102
+ def warn_changed_files_without_subjects
103
+ canonical = ->(file) { Selection::CoverageMap.canonical(Text.utf8_identity(file)) }
104
+ included = @files.map(&canonical)
105
+ matched = changed_subjects(@discovered).map { |subject| canonical.call(subject.file) }
106
+ unmatched = (@changed_lines.keys.select { |file| file.end_with?(".rb") } & included) - matched
107
+ return if unmatched.empty?
108
+
109
+ names = unmatched.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
110
+ more = unmatched.size > 5 ? ", ..." : ""
111
+ @diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
112
+ "the changed lines, so nothing is mutated from them: #{names}#{more}. The " \
113
+ "file may not parse, or every change may sit outside a method.")
114
+ end
115
+
116
+ # A changed Ruby file that an include pattern covers but c.exclude
117
+ # removes yields zero candidates with no other sign - this repo's own
118
+ # config excludes lib/saboteur/execution/**, so harness changes once
119
+ # dogfooded as a clean empty scope (#282). Names at most five.
120
+ def warn_excluded_changed_files
121
+ untracked = Selection::UntrackedChanges.paths(@root)
122
+ hits = excluded_territory & (@changed_lines.keys + untracked)
123
+ return if hits.empty?
124
+
125
+ names = hits.sort.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
126
+ more = hits.size > 5 ? ", ..." : ""
127
+ @diagnostics.warn("--since: #{hits.size} changed Ruby file(s) are covered by c.include but removed by " \
128
+ "c.exclude, so nothing is mutated from them: #{names}#{more}. Narrow or remove the " \
129
+ "c.exclude entry, or pass the file as a positional source operand (configured " \
130
+ "excludes are then ignored).")
131
+ end
132
+
133
+ # Ruby files an include pattern and an exclude pattern both match,
134
+ # canonicalized the way the diff map keys are.
135
+ def excluded_territory
136
+ canonical = lambda do |file|
137
+ Selection::CoverageMap.canonical(File.expand_path(Text.utf8_identity(file), @config.root))
138
+ end
139
+ territory = @config["include"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
140
+ excluded = @config["exclude"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
141
+ (territory & excluded).select { |file| file.end_with?(".rb") }.map(&canonical)
142
+ end
143
+
65
144
  # A method whose only changes are comments, blank lines or spacing has
66
145
  # nothing new to test.
67
146
  def drop_unchanged_syntax(subjects, ref)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "shellwords"
6
+ require_relative "../text"
6
7
  require_relative "data_inputs"
7
8
 
8
9
  module Saboteur
@@ -56,7 +57,8 @@ module Saboteur
56
57
  # `--require name` resolves through RSpec's load path: spec/, lib/ and
57
58
  # any -I directory.
58
59
  def required_by_options
59
- tokens = option_files.flat_map { |file| Shellwords.split(File.read(file)) } + @config["rspec_args"]
60
+ tokens = option_files.flat_map { |file| Shellwords.split(Text.utf8_identity(File.read(file))) } +
61
+ @config["rspec_args"]
60
62
  load_path = LOAD_PATH + option_values(tokens, "-I", "--include")
61
63
  option_values(tokens, *REQUIRE_FLAGS).filter_map { |name| resolve(name, load_path) }
62
64
  end