saboteur 0.4.0 → 0.6.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. metadata +46 -2
@@ -10,6 +10,10 @@ module Saboteur
10
10
  # or mutation source is used. This catches observed instability, not all
11
11
  # possible flaky behaviour.
12
12
  class BaselineGuard
13
+ EXCERPT_LINES = 10
14
+ EXCERPT_WIDTH = 200
15
+ EXCERPT_SCAN_LINES = 5000
16
+
13
17
  def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
14
18
  @config = config
15
19
  @integration = integration
@@ -45,29 +49,29 @@ module Saboteur
45
49
 
46
50
  def check(number)
47
51
  Execution::ForkReadiness.prepare_heap
48
- heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}")
52
+ heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
49
53
  result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
50
54
  record(writer, number)
51
55
  end
52
56
  refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
53
57
  # Before the payload checks: a child killed by a signal (the OOM
54
- # killer, say) writes nothing, and the signal is the explanation.
55
- if result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
56
- refuse(number, unsuccessful_exit(result.exit_status))
57
- end
58
+ # killer, say) writes nothing, and the signal is the explanation. A
59
+ # child the supervisor itself killed after it had reported is not a
60
+ # failure: its payload is checked below.
61
+ refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
58
62
  refuse(number, "the child did not report a result") if result.payload.empty?
59
63
  refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
60
64
  parse_result(number, result.payload)
61
65
  end
62
66
 
63
67
  # A signaled child (the OOM killer, say) can leave an empty log,
64
- # which reads like a suite failure; name the signal and hint the
65
- # likely cause instead.
66
- def unsuccessful_exit(status)
68
+ # which reads like a suite failure; name the signal. The OOM hint is
69
+ # printed only when the supervisor saw the process tree's memory grow.
70
+ def unsuccessful_exit(status, memory_pressure)
67
71
  return "the child exited with status #{status.exitstatus}" unless status.signaled?
68
72
 
69
73
  signal = "SIG#{Signal.signame(status.termsig)}"
70
- hint = signal == "SIGKILL" ? " - possible OOM, check memory" : ""
74
+ hint = signal == "SIGKILL" && memory_pressure ? " - possible OOM, check memory" : ""
71
75
  "the child was killed by #{signal}#{hint}"
72
76
  end
73
77
 
@@ -108,8 +112,26 @@ module Saboteur
108
112
 
109
113
  def refuse(number, reason)
110
114
  raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
111
- "See the log at #{log_path(number)}. Fix the suite or its setup, then run saboteur again " \
112
- "with the same seed. No mutants were started."
115
+ "#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
116
+ "setup, then run saboteur again with the same seed. No mutants were started."
117
+ end
118
+
119
+ # The first lines of the failure, inline, so the cause is readable
120
+ # without opening the log: RSpec's "Failures:" section when the log has
121
+ # one, otherwise the last lines of the log.
122
+ def failure_excerpt(number)
123
+ lines = log_lines(number)
124
+ return "" if lines.empty?
125
+
126
+ start = lines.index { |line| line.start_with?("Failures:") }
127
+ shown = start ? lines[start, EXCERPT_LINES] : lines.last(EXCERPT_LINES)
128
+ "The log says:\n#{shown.map { |line| " #{line.slice(0, EXCERPT_WIDTH)}" }.join("\n")}\n"
129
+ end
130
+
131
+ def log_lines(number)
132
+ File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES).reject { |line| line.strip.empty? }
133
+ rescue SystemCallError
134
+ []
113
135
  end
114
136
  end
115
137
  end
@@ -0,0 +1,95 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Trivial compiler equivalence: a mutant whose method compiles to the
7
+ # same instruction sequence as the original cannot change behaviour, so
8
+ # running it can only produce a false survivor. The comparison walks the
9
+ # compiled instruction tree and drops only position metadata (line
10
+ # numbers, line events, source spans, node ids, file paths). Operands,
11
+ # including string and numeric literals, are compared exactly (2 and 2.0 differ, and so do +0.0 and -0.0).
12
+ class BytecodeEquivalence
13
+ ISEQ_MAGIC = "YARVInstructionSequence/SimpleDataFormat"
14
+ # Operand types whose eql? is exact. Anything else (an unknown object,
15
+ # a type that could hold a Float) is never called equal, so its
16
+ # mutant still runs.
17
+ PLAIN = [Integer, String, Symbol, Rational, Regexp, NilClass, TrueClass, FalseClass].freeze
18
+ POSITION_EVENT = /\ARUBY_EVENT_/
19
+
20
+ # True when mutated_text compiles to the same instructions as the
21
+ # original subject source under the same compile options. Anything the
22
+ # compiler cannot judge counts as not equivalent, so the mutant still
23
+ # runs. frozen_string_literal: whether the file carries that magic
24
+ # comment, which changes how string literals compile.
25
+ def equivalent?(original_text, mutated_text, frozen_string_literal: false)
26
+ original = fingerprint_of_original(original_text, frozen_string_literal)
27
+ return false unless original
28
+
29
+ exact?(original, fingerprint(mutated_text, frozen_string_literal))
30
+ end
31
+
32
+ private
33
+
34
+ # Mutants of one method arrive together, so the last original is enough.
35
+ def fingerprint_of_original(text, frozen)
36
+ key = [text, frozen]
37
+ return @original_fingerprint if key == @original_key
38
+
39
+ @original_key = key
40
+ @original_fingerprint = fingerprint(text, frozen)
41
+ end
42
+
43
+ def fingerprint(text, frozen)
44
+ options = { frozen_string_literal: frozen }
45
+ strip_positions(RubyVM::InstructionSequence.compile(text, nil, nil, 1, options).to_a)
46
+ rescue SyntaxError, StandardError
47
+ nil
48
+ end
49
+
50
+ # Structural equality that never coerces: an Integer is not a Float,
51
+ # and +0.0 is not -0.0 (1.0 / +0.0 and 1.0 / -0.0 are different
52
+ # infinities), which eql? alone would not tell apart.
53
+ def exact?(left, right)
54
+ case left
55
+ when Array then right.is_a?(Array) && left.size == right.size && left.zip(right).all? { |l, r| exact?(l, r) }
56
+ when Hash, Complex, Range then right.instance_of?(left.class) && exact?(parts(left), parts(right))
57
+ when Float then right.is_a?(Float) && [left].pack("G") == [right].pack("G")
58
+ when *PLAIN then left.eql?(right)
59
+ else false
60
+ end
61
+ end
62
+
63
+ # The values a composite operand is made of, compared one by one.
64
+ def parts(value)
65
+ case value
66
+ when Hash then value.to_a
67
+ when Complex then value.rectangular
68
+ else [value.begin, value.end, value.exclude_end?]
69
+ end
70
+ end
71
+
72
+ def strip_positions(node)
73
+ return node unless node.is_a?(Array)
74
+ return node.map { |child| strip_positions(child) } unless node.first == ISEQ_MAGIC
75
+
76
+ _magic, major, minor, format, _misc, name, _path, _real_path, _first_line, type, locals, params, catches,
77
+ body = node
78
+ [major, minor, format, name, type, locals, strip_positions(params), strip_positions(catches),
79
+ strip_body(body)]
80
+ end
81
+
82
+ def strip_body(body)
83
+ body.reject { |entry| position?(entry) }.map { |entry| strip_positions(entry) }
84
+ end
85
+
86
+ # Line numbers and line/call/return events depend only on layout.
87
+ def position?(entry)
88
+ case entry
89
+ when Integer, POSITION_EVENT then true
90
+ else false
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ module Saboteur
7
+ class Engine
8
+ # Watches the working tree while mutants run (issue #102). A mutant that
9
+ # slips past the hazard guard and deletes tracked files must abort the
10
+ # run, not be reported as a survivor. The snapshot is the tracked files
11
+ # (git ls-files) when the tree is a repository, else every file under the
12
+ # root; the tool's own cache directory is excluded in both cases, and the
13
+ # presence of .git is watched separately.
14
+ class Canary
15
+ GIT_DIR = ".git"
16
+
17
+ def initialize(root, cache_dir: ".saboteur")
18
+ @root = root
19
+ @cache_dir = cache_dir.to_s
20
+ @snapshot = inventory
21
+ end
22
+
23
+ # After a subject's mutants: any file present at snapshot time and gone
24
+ # now means a mutant deleted it. digest names the most recent mutant,
25
+ # the likely cause.
26
+ def check!(subject: nil, digest: nil)
27
+ missing = @snapshot - inventory
28
+ return if missing.empty?
29
+
30
+ of = " of #{subject.id}" if subject
31
+ culprit = digest ? " The most recent mutant, #{digest}#{of}, is the likely cause." : ""
32
+ raise Saboteur::CanaryTripped,
33
+ "canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
34
+ "(first: #{missing.first}).#{culprit} The canary watches tracked files and .git presence " \
35
+ "and only detects the loss after the fact; it cannot restore anything. Recover from your " \
36
+ "own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
37
+ "unless you have another copy."
38
+ end
39
+
40
+ private
41
+
42
+ def inventory
43
+ files = git? ? git_tracked_files : walk_files
44
+ files << GIT_DIR if File.exist?(File.join(@root, GIT_DIR))
45
+ files.uniq
46
+ end
47
+
48
+ def git?
49
+ File.directory?(File.join(@root, GIT_DIR)) && system("git", "--version", %i[out err] => File::NULL)
50
+ end
51
+
52
+ def git_tracked_files
53
+ out, status = Open3.capture2("git", "ls-files", "-z", chdir: @root)
54
+ return walk_files unless status.success?
55
+
56
+ # ls-files reads the index, which still names a deleted file; keep
57
+ # only paths present in the worktree so a deletion shows up.
58
+ out.split("\0").reject { |rel| cache_path?(rel) }
59
+ .select { |rel| File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel)) }
60
+ end
61
+
62
+ def walk_files
63
+ Dir.glob(File.join(@root, "**", "*"), File::FNM_DOTMATCH)
64
+ .select { |path| File.file?(path) }
65
+ .map { |path| path.delete_prefix("#{@root}/") }
66
+ .reject { |rel| rel.start_with?("#{GIT_DIR}/") || cache_path?(rel) }
67
+ end
68
+
69
+ def cache_path?(rel)
70
+ rel.start_with?("#{@cache_dir}/")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -6,7 +6,7 @@ module Saboteur
6
6
  # Selects a bounded list for each covered subject before workers fork.
7
7
  # Subject skips are not mutants and do not enter the mutation score.
8
8
  class CandidateSelection
9
- attr_reader :skipped_subjects
9
+ attr_reader :skipped_subjects, :selected
10
10
 
11
11
  def initialize(candidates:, map:, limit:, diagnostics:)
12
12
  @candidates = candidates
@@ -14,21 +14,34 @@ module Saboteur
14
14
  @limit = limit
15
15
  @diagnostics = diagnostics
16
16
  @skipped_subjects = {}
17
+ @selected = {}
17
18
  end
18
19
 
19
20
  def for(subject, scope: nil)
20
- if @map.tests_for(subject).empty?
21
- @skipped_subjects[subject] = "no coverage"
22
- return []
21
+ reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
22
+ if reason
23
+ @skipped_subjects[subject] = reason
24
+ return @selected[subject] = []
23
25
  end
24
26
 
25
27
  list = @candidates.for(subject)
26
28
  list = scope.restrict(subject, list) if scope
27
29
  omitted = list.size - @limit
28
30
  if omitted.positive?
31
+ @diagnostics.record_omission(subject, count: omitted, limit: @limit)
29
32
  @diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
30
33
  end
31
- list.first(@limit)
34
+ @selected[subject] = list.first(@limit)
35
+ end
36
+
37
+ private
38
+
39
+ # Load-only code is not "no coverage": it ran, just never inside a
40
+ # test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
41
+ def skip_reason_for(subject)
42
+ return "no coverage" unless @map.ran_at_load?(subject.file, subject.coverage_lines)
43
+
44
+ Selection::CoverageMap::RAN_AT_LOAD
32
45
  end
33
46
  end
34
47
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "prism"
5
+ require_relative "../inline_suppression"
5
6
 
6
7
  module Saboteur
7
8
  class Engine
@@ -12,7 +13,10 @@ module Saboteur
12
13
  def self.build_sources(config)
13
14
  root = File.realpath(config.root)
14
15
  config["sources"].map do |entry|
15
- next MutationSource::Builtin.new(arid: config["arid"]) if entry == "builtin"
16
+ if entry == "builtin"
17
+ next MutationSource::Builtin.new(arid: config["arid"], operators: config["operators"],
18
+ exclude_operators: config["exclude_operators"])
19
+ end
16
20
 
17
21
  file, const = entry.split(":", 2)
18
22
  raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
@@ -22,37 +26,88 @@ module Saboteur
22
26
  end
23
27
  end
24
28
 
25
- def initialize(sources, diagnostics)
29
+ def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new)
26
30
  @sources = sources
27
31
  @diagnostics = diagnostics
32
+ @hazard_guard = hazard_guard
33
+ @bytecode = bytecode
28
34
  end
29
35
 
30
36
  def for(subject)
31
- return [] unless parses?(subject.source)
37
+ unless parses?(subject.source)
38
+ @diagnostics.record_generation(subject, source: "subject", reason: "invalid_subject_syntax")
39
+ return []
40
+ end
32
41
 
33
42
  seen = Set[subject.source]
34
- list = @sources.flat_map { |source| source.mutations_for(subject) }.filter_map do |mutation|
43
+ list = @sources.flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
35
44
  candidate(subject, mutation, seen)
36
45
  end
46
+ list = suppress_inline(list, subject)
47
+ list = suppress_hazards(list, subject)
37
48
  list.sort_by.with_index { |(mutation, _), index| [mutation.coarse ? 0 : 1, index] }
38
49
  end
39
50
 
40
51
  private
41
52
 
53
+ def suppress_inline(list, subject)
54
+ directives = InlineSuppression.new(subject.file, subject.file_source)
55
+ list.reject do |mutation, _text|
56
+ directive = directives.reason(subject, mutation)
57
+ @diagnostics.record_inline_suppression(subject, mutation, directive) if directive
58
+ directive
59
+ end
60
+ end
61
+
62
+ # The hazard guard (issue #102) refuses mutants that would leave a
63
+ # destructive call running with altered inputs; each refusal is counted.
64
+ def suppress_hazards(list, subject)
65
+ return list unless @hazard_guard
66
+
67
+ kept, suppressed = @hazard_guard.partition(list, subject)
68
+ suppressed.each { |(mutation, _)| @diagnostics.record_hazard_suppression(subject, mutation) }
69
+ kept
70
+ end
71
+
72
+ def proposals(source, subject)
73
+ proposals = source.mutations_for(subject)
74
+ if proposals.empty?
75
+ @diagnostics.record_generation(subject, source: source_name(source), reason: "empty_generation")
76
+ end
77
+ proposals
78
+ end
79
+
80
+ def source_name(source)
81
+ name = source.name if source.respond_to?(:name)
82
+ name || "anonymous"
83
+ end
84
+
85
+ def record_drop(subject, mutation, reason)
86
+ @diagnostics.record_generation(subject, source: mutation.source, operator: mutation.operator, reason: reason)
87
+ nil
88
+ end
89
+
42
90
  def candidate(subject, mutation, seen)
43
91
  text = subject.apply(mutation.edits)
44
- return if seen.include?(text)
92
+ return record_drop(subject, mutation, "unchanged") if text == subject.source
93
+ return record_drop(subject, mutation, "duplicate") if seen.include?(text)
45
94
 
46
95
  seen << text
47
96
  unless parses?(text)
48
97
  @diagnostics.count_invalid_mutation
49
- return
98
+ return record_drop(subject, mutation, "invalid_syntax")
50
99
  end
51
100
 
101
+ return record_drop(subject, mutation, "equivalent_bytecode") if equivalent?(subject, text)
102
+
52
103
  [mutation, text]
53
104
  rescue ArgumentError => e
54
105
  @diagnostics.warn("#{subject.id}: dropped #{mutation.source} mutation: #{e.message}")
55
- nil
106
+ record_drop(subject, mutation, "invalid_edit")
107
+ end
108
+
109
+ def equivalent?(subject, text)
110
+ @bytecode.equivalent?(subject.source, text, frozen_string_literal: subject.frozen_literals)
56
111
  end
57
112
 
58
113
  def parses?(text)
@@ -15,12 +15,13 @@ module Saboteur
15
15
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
16
16
  LOG = "coverage-build.log"
17
17
 
18
- def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
18
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {})
19
19
  @config = config
20
20
  @integration = integration
21
21
  @hooks = hooks
22
22
  @supervisor = supervisor
23
23
  @diagnostics = diagnostics
24
+ @load_lines = load_lines
24
25
  @root = File.realpath(config.root)
25
26
  end
26
27
 
@@ -31,6 +32,8 @@ module Saboteur
31
32
 
32
33
  map = refuse_empty_map(build(files), files)
33
34
  map.save(path)
35
+ File.write(File.join(@config.path("cache_dir"), "preview.json"),
36
+ JSON.generate(path: path, fingerprint: RunFingerprint.new(config: @config).snapshot.digest))
34
37
  map
35
38
  end
36
39
 
@@ -57,42 +60,59 @@ module Saboteur
57
60
  # The suite child forked next inherits this heap; pack it first, to
58
61
  # reduce later copying of shared pages.
59
62
  Execution::ForkReadiness.prepare_heap
60
- heartbeat = @diagnostics.progress_heartbeat("building coverage map")
63
+ heartbeat = @diagnostics.progress_heartbeat("building coverage map").watch(log_path)
61
64
  result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
62
65
  record(writer, files)
63
66
  end
67
+ map_from(result)
68
+ end
69
+
70
+ def map_from(result)
64
71
  raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
65
72
  raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
66
73
 
67
74
  data = JSON.parse(result.payload)
68
75
  raise Error, "coverage map build failed: #{data['error']}" if data["error"]
69
76
 
70
- Selection::CoverageMap.from_json(result.payload)
77
+ with_parent_load_lines(Selection::CoverageMap.from_json(result.payload))
78
+ end
79
+
80
+ # The parent's load lines merge into the freshly built map: the forked
81
+ # build child cannot inherit the parent's Coverage counters.
82
+ def with_parent_load_lines(map)
83
+ @load_lines.empty? ? map : map.with_load_lines(@load_lines)
71
84
  end
72
85
 
73
- # A whole-suite build that records no line in any included file fails
74
- # the run (exit 3): usually the setup hid the app from Coverage
75
- # (Bootsnap's compile cache is the default Rails case); it can also
76
- # mean no test in the suite exercises any included file. Either way a
77
- # run on this map would test nothing, and exit 0 would lie. With
78
- # explicit tests (--test) an empty map is a legitimate outcome
86
+ # A whole-suite build that records nothing - no line hit and no method
87
+ # call - in any included file fails the run (exit 3): usually no test
88
+ # in the suite exercises these files, or the setup hid the app from
89
+ # Coverage (Bootsnap's compile cache is the default Rails case). An
90
+ # all-endless-def file records method calls only and passes. Either way
91
+ # a run on a truly empty map would test nothing, and exit 0 would lie.
92
+ # With explicit tests (--test) an empty map is a legitimate outcome
79
93
  # instead: the user narrowed the suite to specs that may not touch
80
94
  # these sources, so the map passes with a warning. Applies to a cached
81
95
  # map too, so a map cached before this check cannot keep a run green,
82
96
  # and the guard runs before saving, so an empty map is never cached.
83
97
  def refuse_empty_map(map, files)
84
- return map if files.empty? || map.lines.any? { |_file, by_line| by_line.any? }
98
+ return map if files.empty? || !map.empty?
85
99
 
86
100
  if @config["tests"].empty?
87
- raise Error, "Coverage recorded no lines in #{files.size} included files during a whole-suite " \
88
- "build. Is Bootsnap's compile cache on? If not, no test may exercise these files. " \
89
- "Run `saboteur doctor`."
101
+ raise Error, "Coverage recorded nothing in #{files.size} included files during a whole-suite " \
102
+ "build. No test in the suite may exercise these files, or the setup hid the app " \
103
+ "from Coverage#{bootsnap_note}. Run `saboteur doctor`."
90
104
  end
91
- @diagnostics.warn("Coverage recorded no lines in #{files.size} included files; " \
105
+ @diagnostics.warn("Coverage recorded nothing in #{files.size} included files; " \
92
106
  "the selected specs may not exercise them.")
93
107
  map
94
108
  end
95
109
 
110
+ # Bootsnap is named only when it is loaded: blaming a library the app
111
+ # does not use sends the reader to the wrong place.
112
+ def bootsnap_note
113
+ " (Bootsnap's compile cache is the default Rails case)" if defined?(::Bootsnap)
114
+ end
115
+
96
116
  def died(status)
97
117
  how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
98
118
  else "exited with status #{status&.exitstatus}"