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
@@ -32,8 +32,16 @@ module Saboteur
32
32
 
33
33
  # Where a verdict comes from: the exact selection and the state of the working
34
34
  # source before the run started. subjects, tests and sources are arrays.
35
+ # typed_settings: the settings keys typed on the command line, so the
36
+ # generated commands can tell a typed empty list (which may clear a
37
+ # config-file value) from one the config itself supplies.
35
38
  Provenance = Data.define(:ref, :seed, :head, :working_tree, :changed_files, :mode, :json_report, :subjects,
36
- :tests, :sources, :config_path, :digest, :cache, :incremental, :settings)
39
+ :tests, :sources, :config_path, :digest, :cache, :incremental, :settings,
40
+ :typed_settings) do
41
+ def initialize(typed_settings: [], **fields)
42
+ super
43
+ end
44
+ end
37
45
 
38
46
  class << self
39
47
  def call(report, provenance)
@@ -19,7 +19,8 @@ module Saboteur
19
19
  SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
20
20
  timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html",
21
21
  operators: "--operators", exclude_operators: "--exclude-operators",
22
- baseline_passes: "--baseline-passes", max_duration: "--max-duration" }.freeze
22
+ baseline_passes: "--baseline-passes", max_duration: "--max-duration",
23
+ shard: "--shard" }.freeze
23
24
 
24
25
  # CLI-only boolean switches recorded in settings, mapped by value, so
25
26
  # an explicit false (--no-max-duration-ok over a config true) survives
@@ -70,11 +71,17 @@ module Saboteur
70
71
  tests.flat_map { |test| ["--test", test] }
71
72
  end
72
73
 
73
- def setting_words(provenance)
74
+ # An empty list nobody typed came from the config file or its
75
+ # default, which the command reads again, so its flag is left out
76
+ # (#317). A typed empty list stays: it may clear a config value.
77
+ def setting_words(provenance, replay: false)
74
78
  SETTING_FLAGS.flat_map do |key, flag|
79
+ next [] if replay && key == :shard
75
80
  next [] unless provenance.settings.key?(key)
76
81
 
77
82
  value = provenance.settings[key]
83
+ next [] if value == [] && !provenance.typed_settings.include?(key)
84
+
78
85
  [flag, value.is_a?(Array) ? value.join(",") : value.to_s]
79
86
  end
80
87
  end
@@ -88,7 +95,7 @@ module Saboteur
88
95
  provenance.subjects.each { |subject| words.push("--subject", subject) }
89
96
  words << "--all" if provenance.settings[:all] && provenance.tests.empty?
90
97
  words.push(*test_words(provenance.tests))
91
- words.concat(setting_words(provenance))
98
+ words.concat(setting_words(provenance, replay: replay))
92
99
  words.concat(mode_words(provenance))
93
100
  words.push("--id", provenance.digest) if provenance.digest && !replay
94
101
  words
@@ -14,6 +14,7 @@ module Saboteur
14
14
  class << self
15
15
  def reasons(report)
16
16
  reasons = []
17
+ shard_reason(reasons, report)
17
18
  add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
18
19
  load_only = report.counts[:ran_at_load] + report.skipped_subjects.values.count do |reason|
19
20
  reason == Selection::CoverageMap::RAN_AT_LOAD
@@ -35,6 +36,16 @@ module Saboteur
35
36
 
36
37
  private
37
38
 
39
+ # A shard always runs a subset of the planned inventory by contract, even
40
+ # when this shard happened to select every planned mutant (count 0).
41
+ def shard_reason(reasons, report)
42
+ shard = report.shard
43
+ return unless shard
44
+
45
+ reasons << { reason: "shard", count: shard.planned_count - shard.selected_count,
46
+ meaning: "planned mutants assigned to other shards" }
47
+ end
48
+
38
49
  # Source the selection could not read at all: its subjects never
39
50
  # entered the run, so a clean or complete verdict would be wrong.
40
51
  def discovery_failures(reasons, report)
@@ -26,10 +26,11 @@ module Saboteur
26
26
  counts: counts(report), score: score(report),
27
27
  scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
28
28
  incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
29
- findings: findings(report, shown, snippet_limit),
29
+ findings: findings(report, shown, snippet_limit)
30
+ }.merge(report.shard ? { shard: report.shard.to_h } : {}).merge(
30
31
  warnings: report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] },
31
32
  next: next_steps(report, provenance)
32
- }
33
+ )
33
34
  end
34
35
 
35
36
  private
@@ -95,7 +96,7 @@ module Saboteur
95
96
 
96
97
  def shown_findings(listed, shown, snippet_limit)
97
98
  listed.first(shown).map do |finding|
98
- finding.slice(:digest, :line, :endLine, :operator, :coarse)
99
+ finding.slice(:digest, :line, :endLine, :operator, :coarse, :classification)
99
100
  .merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
100
101
  original: clip(finding[:original], snippet_limit),
101
102
  replacement: clip(finding[:replacement], snippet_limit))
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "survivor_ranking"
5
+ require_relative "survivor_classification"
5
6
 
6
7
  module Saboteur
7
8
  module Reporter
@@ -59,8 +60,10 @@ module Saboteur
59
60
  original: joined_texts(result) { |edit| original_text(result, edit) },
60
61
  replacement: joined_texts(result, &:replacement),
61
62
  edits: entries,
62
- coarse: result.mutation.coarse
63
- }
63
+ coarse: result.mutation.coarse,
64
+ # Why the survivor is worth a look, as a label only (#192).
65
+ classification: SurvivorClassification.label(result)
66
+ }.compact
64
67
  entry = with_changed_lines(report, result, entry)
65
68
  result.ignored ? entry.merge(reason: result.ignored) : entry
66
69
  end
@@ -4,6 +4,10 @@
4
4
  module Saboteur
5
5
  module Reporter
6
6
  # v1 stays readable, but v2 records require versioned operator evidence.
7
+ # A policy stamped with an older POLICY_VERSION is shape-checked only:
8
+ # the operator set has moved on, so it never recomputes equal - and it
9
+ # never compares equal to a current run's policy either, which is what
10
+ # keeps trend from mixing operator sets (#135).
7
11
  module HistoryPolicy
8
12
  def validate_header(record, fields)
9
13
  version = record.is_a?(Hash) && record["version"]
@@ -18,6 +22,11 @@ module Saboteur
18
22
  raise Error, "score history contains invalid operator policy" unless value.is_a?(Hash)
19
23
 
20
24
  fields(value, %w[version selected excluded effective])
25
+ policy_version = value["version"]
26
+ unless policy_version.is_a?(Integer) &&
27
+ policy_version.between?(1, MutationSource::OperatorRegistry::POLICY_VERSION)
28
+ raise Error, "score history contains invalid operator policy"
29
+ end
21
30
  raise Error, "score history contains invalid operator policy" if value["effective"].empty?
22
31
 
23
32
  lists = value.values_at("selected", "excluded", "effective")
@@ -25,6 +34,8 @@ module Saboteur
25
34
  raise Error, "score history contains invalid operator policy"
26
35
  end
27
36
 
37
+ return if policy_version != MutationSource::OperatorRegistry::POLICY_VERSION
38
+
28
39
  expected = MutationSource::OperatorRegistry.policy(value["selected"], value["excluded"])
29
40
  raise Error, "score history contains invalid operator policy" unless value == expected
30
41
  rescue ConfigError, NoMethodError, TypeError
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "score_history"
5
+ require_relative "history_record"
6
+
7
+ module Saboteur
8
+ module Reporter
9
+ # Read side of the append-only score history for `saboteur badge` and
10
+ # `saboteur trend` (#474). Every record is re-validated on read and the
11
+ # file discipline mirrors ScoreHistory: regular file, no symlink,
12
+ # byte-capped lines, no repair of imported evidence.
13
+ module HistoryReader
14
+ module_function
15
+
16
+ def read(path)
17
+ records = []
18
+ File.open(path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
19
+ raise Error, "score history must be a regular file" unless file.stat.file?
20
+
21
+ file.flock(File::LOCK_SH)
22
+ limit = ScoreHistory::MAX_RECORD_BYTES
23
+ while (line = file.gets(limit + 1))
24
+ raise Error, "score history record exceeds byte limit" if line.bytesize > limit
25
+ raise Error, "score history contains a truncated record" unless line.end_with?("\n")
26
+
27
+ records << HistoryRecord.parse(line)
28
+ end
29
+ end
30
+ records
31
+ rescue SystemCallError, IOError => e
32
+ raise Error, "score history cannot be read: #{e.message}"
33
+ end
34
+
35
+ # The ScoreHistory change rules: same record version, selection,
36
+ # inventory and operator policy (a null policy never compares), both
37
+ # runs complete.
38
+ def comparable?(previous, current)
39
+ previous["version"] == current["version"] &&
40
+ previous["selection"] == current["selection"] &&
41
+ previous["inventory"] == current["inventory"] &&
42
+ previous["incomplete"].empty? && current["incomplete"].empty? &&
43
+ !previous["operatorPolicy"].nil? && previous["operatorPolicy"] == current["operatorPolicy"]
44
+ end
45
+ end
46
+ end
47
+ end
@@ -4,6 +4,8 @@
4
4
  require "cgi"
5
5
  require "fileutils"
6
6
 
7
+ require_relative "survivor_classification"
8
+
7
9
  module Saboteur
8
10
  module Reporter
9
11
  # One self-contained HTML page: inline style, no script, no network. It is
@@ -23,7 +25,9 @@ module Saboteur
23
25
  .status{border-radius:3px;padding:0 .4rem;color:#fff;white-space:nowrap}
24
26
  .Killed{background:#2e7d32}.Survived{background:#c62828}.Timeout{background:#b45309}
25
27
  .RuntimeError{background:#6a1b9a}.NoCoverage{background:#455a64}.Ignored{background:#616161}
26
- .Accepted{background:#8d6e63}
28
+ .Accepted,.Triage_accepted_equivalent{background:#8d6e63}
29
+ .Triage_wont_fix{background:#616161}.Triage_todo{background:#b45309}
30
+ .classification{border-radius:3px;padding:0 .4rem;background:#eceff1;color:#37474f;white-space:nowrap}
27
31
  .warning{background:#fff8e1;border-left:4px solid #f9a825;margin:.3rem 0;padding:.3rem .6rem;overflow-wrap:anywhere}
28
32
  code,pre{font-family:ui-monospace,monospace;font-size:13px}
29
33
  pre{display:inline-block;vertical-align:top;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}summary{cursor:pointer;font-weight:600;overflow-wrap:anywhere}
@@ -195,12 +199,30 @@ module Saboteur
195
199
  def row_status(mutant, result)
196
200
  badge = status_label(mutant[:status])
197
201
  # Only survivors are ever put on the ignore list.
198
- result.ignored ? "#{badge} #{status_label('Accepted')}" : badge
202
+ if result.ignored || result.triage_state
203
+ # An entry without a state predates triage states: render Accepted.
204
+ badge = if result.triage_state.nil?
205
+ "#{badge} #{status_label('Accepted')}"
206
+ else
207
+ "#{badge} #{status_label(triage_badge(result), css_class: "Triage_#{result.triage_state}")}"
208
+ end
209
+ end
210
+ # The survivor's classification, as a label only (#192).
211
+ label = SurvivorClassification.label(result)
212
+ label ? "#{badge} <span class=\"classification\">#{escape(label)}</span>" : badge
199
213
  end
200
214
 
201
215
  # One edit shows the original text and its replacement. A mutant with
202
216
  # several edits shows only the replacements, because one location cannot
203
217
  # stand for all of them.
218
+ def triage_badge(result)
219
+ case result.triage_state
220
+ when "wont_fix" then "Won\u2019t fix"
221
+ when "todo" then "Todo"
222
+ else "Accepted"
223
+ end
224
+ end
225
+
204
226
  def change(mutant, result)
205
227
  edits = result.mutation.edits
206
228
  return "<pre>#{escape(mutant[:replacement])}</pre>" unless edits.size == 1
@@ -212,8 +234,8 @@ module Saboteur
212
234
  "<pre>#{escape(edit.replacement)}</pre>"
213
235
  end
214
236
 
215
- def status_label(status)
216
- %(<span class="status #{status}">#{status}</span>)
237
+ def status_label(status, css_class: status)
238
+ %(<span class="status #{css_class}">#{status}</span>)
217
239
  end
218
240
 
219
241
  def escape(text)
@@ -20,6 +20,12 @@ module Saboteur
20
20
  join(result, &:replacement)
21
21
  end
22
22
 
23
+ # A mutant that only deletes: every edit's replacement is empty.
24
+ # Rendered as "removes X" rather than "X -> " with a blank side.
25
+ def removal?(result)
26
+ sorted_edits(result).all? { |edit| edit.replacement.empty? }
27
+ end
28
+
23
29
  def sorted_edits(result)
24
30
  result.mutation.edits.sort_by(&:start_byte)
25
31
  end
@@ -39,12 +39,24 @@ module Saboteur
39
39
  rejectedKills: report.rejected_kills,
40
40
  warnings: report.warnings,
41
41
  framework: { name: "saboteur", version: VERSION },
42
- saboteur: { findings: Findings.build(report), scores: scores(report),
43
- inlineSuppressions: inline_audit(report) }
44
- .merge(operatorPolicy: report.operator_policy).merge(exit_cause ? { exitCause: exit_cause } : {})
42
+ saboteur: saboteur_enrichment(report, exit_cause)
45
43
  }.merge(report.seed ? { config: { seed: report.seed } } : {})
46
44
  end
47
45
 
46
+ # saboteur-namespaced keys beyond Stryker's schema: findings and
47
+ # scores, then the optional policy, shard, exit-cause and memory keys.
48
+ def self.saboteur_enrichment(report, exit_cause)
49
+ { findings: Findings.build(report), scores: scores(report),
50
+ inlineSuppressions: inline_audit(report) }
51
+ .merge(operatorPolicy: report.operator_policy).merge(shard(report))
52
+ .merge(exit_cause ? { exitCause: exit_cause } : {})
53
+ .merge(report.memory ? { memory: report.memory } : {})
54
+ end
55
+
56
+ def self.shard(report)
57
+ report.shard ? { shard: report.shard.to_h } : {}
58
+ end
59
+
48
60
  def self.inline_audit(report)
49
61
  report.inline_suppressions.map { |entry| entry.merge(file: relative(entry[:file], report.root)) }
50
62
  end
@@ -107,7 +119,14 @@ module Saboteur
107
119
 
108
120
  def self.optional_fields(result, test_ids)
109
121
  {
122
+ # A rejected kill is a verdict, not a tool error: the tests failed
123
+ # but also fail with the method unchanged. Flag it per mutant;
124
+ # the top-level rejectedKills stays a bare count (#375).
125
+ rejectedKill: (true if result.rejected_kill?),
110
126
  statusReason: status_reason(result),
127
+ # Why the survivor is worth a look, as a label only: scores and
128
+ # the exit code never change (#192).
129
+ classification: SurvivorClassification.label(result),
111
130
  killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
112
131
  coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
113
132
  duration: result.runtime && (result.runtime * 1000).round
@@ -129,6 +148,11 @@ module Saboteur
129
148
  end
130
149
 
131
150
  def self.status_reason(result)
151
+ return "todo: #{result.triage_reason}" if result.triage_state == "todo"
152
+ # The "ignored: " prefix is a public contract consumers classify by
153
+ # (#304 CI): accepted_equivalent stays byte-identical to a plain
154
+ # ignore, and only wont_fix adds a suffix after the reason.
155
+ return "ignored: #{result.ignored} (wont_fix)" if result.triage_state == "wont_fix"
132
156
  return "ignored: #{result.ignored}" if result.ignored
133
157
  return "resource limit: #{result.reason}" if result.status == :resource_limit
134
158
  return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../engine/bytecode_equivalence"
5
+ require_relative "../execution/external_runner"
6
+ require_relative "../mutation_source/builtin"
7
+
8
+ module Saboteur
9
+ module Reporter
10
+ # Why a survivor is worth a look, as a LABEL ONLY (#192): the label
11
+ # lands in reports so a reader can sort real gaps from noise. Every
12
+ # label is a HEURISTIC, not a diagnosis - inspect the survivor before
13
+ # acting on it, and never add a test or ignore a mutant from the label
14
+ # alone. Labels never change scores or the exit code.
15
+ #
16
+ # One label per survivor, first match wins:
17
+ # likely_equivalent - the mutant compiles to the same instructions as
18
+ # the original (the #151/#169 bytecode filter): strong evidence of
19
+ # a no-op mutant, worth checking against the source. First: a
20
+ # no-op mutant's survival says nothing about the suite.
21
+ # arid - every line an edit touches matches an arid pattern
22
+ # (logging and friends, #111/#187). The builtin hint list decides;
23
+ # a configured arid policy does not change the label.
24
+ # missing_test - a test covered the line and passed, which MAY mean
25
+ # the suite reaches the code without pinning this behaviour. The
26
+ # covering list is only the PLANNED coverage: whole-body (coarse)
27
+ # mutants carry method-level guesses, so they never get this
28
+ # label, and neither does anything without real coverage.
29
+ # tool_limit - reserved for survivors saboteur could not observe
30
+ # faithfully (re-eval refusal, external runner without coverage). No
31
+ # result records that signal yet, so the label is never emitted.
32
+ #
33
+ # A survivor with no matching signal stays unlabeled.
34
+ module SurvivorClassification
35
+ LABELS = %w[likely_equivalent arid missing_test tool_limit].freeze
36
+
37
+ class << self
38
+ def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
39
+ return nil unless result.status == :survived
40
+ return "likely_equivalent" if equivalent?(result)
41
+ return "arid" if arid?(result, arid)
42
+ return "missing_test" if possible_test_gap?(result)
43
+
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ # The mutant's own def text, compiled side by side with the
50
+ # original: equal instruction sequences mean behaviour cannot
51
+ # change. Edits index the whole file, so they shift by the def's
52
+ # start byte before splicing into the def text.
53
+ def equivalent?(result)
54
+ subject = result.subject
55
+ original = subject.source
56
+ mutated = result.mutation.edits.sort_by(&:start_byte).reverse.inject(original) do |text, edit|
57
+ from = edit.start_byte - subject.start_byte
58
+ to = edit.end_byte - subject.start_byte
59
+ text.byteslice(0, from) + edit.replacement + text.byteslice(to..)
60
+ end
61
+ Engine::BytecodeEquivalence.new.equivalent?(original, mutated,
62
+ frozen_string_literal: subject.frozen_literals)
63
+ end
64
+
65
+ # covered_by is a PLANNED covering list, not a verdict about what
66
+ # the suite asserts: coarse (whole-body) mutants carry method-level
67
+ # guesses, and a --test-command run records only the pseudo-test id
68
+ # for every mutant, so neither reads as real coverage (#305 QA).
69
+ def possible_test_gap?(result)
70
+ real_coverage = result.covered_by - [Execution::ExternalRunner::PSEUDO_TEST]
71
+ !result.mutation.coarse && real_coverage.any?
72
+ end
73
+
74
+ # Arid only when EVERY line an edit touches reads as arid: an edit
75
+ # that starts on a logging line but removes a composite body is not
76
+ # arid. The operator never decides: removing a logging statement is
77
+ # arid whatever removed it.
78
+ def arid?(result, patterns)
79
+ result.mutation.edits.all? do |edit|
80
+ lines = edited_lines(result.subject, edit)
81
+ lines.any? && lines.all? do |line|
82
+ patterns.any? { |pattern| line.strip.match?(Regexp.new(pattern)) }
83
+ end
84
+ end
85
+ end
86
+
87
+ # Edit offsets are BYTES, so the line lookup runs on the binary
88
+ # view (index/rindex are byte-exact there) and only the extracted
89
+ # lines are decoded, scrubbed so a partial multibyte sequence at an
90
+ # edit boundary cannot raise on the pattern match.
91
+ def edited_lines(subject, edit)
92
+ bytes = subject.file_source.b
93
+ last = [edit.end_byte - 1, edit.start_byte].max
94
+ line_start = (bytes.byteslice(0, edit.start_byte).rindex("\n") || -1) + 1
95
+ line_end = bytes.index("\n", last) || bytes.bytesize
96
+ bytes.byteslice(line_start, line_end - line_start).force_encoding(Encoding::UTF_8).scrub.lines
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -47,14 +47,16 @@ module Saboteur
47
47
  end
48
48
  end
49
49
 
50
- # Mutants on one line share the printed evidence only when it is the
51
- # same for all of them: covering tests (in order), reuse and changed
52
- # lines can each differ per mutant, so each run of neighbours with the
53
- # same tuple is one group and shares evidence above its own mutations.
54
- # Evidence that repeats after another group is printed again, which
55
- # keeps the ranked order.
50
+ # Mutants on one line share the printed evidence when it names the
51
+ # same tests: the covering-test list is compared as a set (#502), so
52
+ # one line's mutants no longer reprint an identical block only because
53
+ # the run measured the killer order per mutant. Reuse and changed
54
+ # lines still group exactly. Each run of neighbours with the same key
55
+ # is one group and shares evidence above its own mutations; evidence
56
+ # that repeats after another group is printed again, which keeps the
57
+ # ranked order. The block prints in the first mutant's run order.
56
58
  def evidence_groups(report, on_line)
57
- on_line.chunk { |result| [result.covered_by, result.reused?, report.changed_lines_for(result)] }
59
+ on_line.chunk { |result| [result.covered_by.uniq.sort, result.reused?, report.changed_lines_for(result)] }
58
60
  .map(&:last)
59
61
  end
60
62
 
@@ -70,9 +72,13 @@ module Saboteur
70
72
  end
71
73
 
72
74
  def mutation(result)
75
+ change = if MutationText.removal?(result)
76
+ "removes #{MutationText.original(result)}"
77
+ else
78
+ "#{MutationText.original(result)} -> #{@palette.paint(:green, MutationText.replacement(result))}"
79
+ end
73
80
  text = " #{@palette.paint(:red, '-')} #{result.mutation.operator} " \
74
- "#{MutationText.original(result)} -> #{@palette.paint(:green, MutationText.replacement(result))} " \
75
- "#{result.digest}"
81
+ "#{change} #{result.digest}"
76
82
  text += " (ignored: #{result.ignored})" if result.ignored
77
83
  @out.puts text
78
84
  hint(result)
@@ -91,6 +91,7 @@ module Saboteur
91
91
  seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
92
92
  @out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
93
93
  "wall #{format('%.2f', report.wall_time)} s#{seed}"
94
+ @out.puts report.shard.line if report.shard
94
95
  @out.puts status_line(report)
95
96
  details(report)
96
97
  @score_table.call(report)
@@ -103,10 +104,12 @@ module Saboteur
103
104
  survived = "survived #{c[:survived]}"
104
105
  ignored = report.survivors.size - report.unignored_survivors.size
105
106
  survived += " (#{ignored} ignored)" if ignored.positive?
107
+ rejected = report.rejected_kills
108
+ rejected_note = " (#{rejected} rejected kill#{'s' if rejected > 1})" if rejected.positive?
106
109
  line = "#{@palette.paint(:green,
107
110
  "killed #{c[:killed]}")} #{@palette.paint(:red,
108
111
  survived)} timeout #{c[:timeout]} " \
109
- "resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
112
+ "resource limit #{c[:resource_limit]} error #{c[:error]}#{rejected_note} skipped #{c[:skipped]}"
110
113
  line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
111
114
  line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
112
115
  line += " unverified #{c[:unverified]}" if c[:unverified].positive?
@@ -122,11 +125,19 @@ module Saboteur
122
125
 
123
126
  def details(report)
124
127
  breakdown = report.score_breakdown
125
- @out.puts "score #{percent(breakdown.value)}" \
126
- "#{if @verbose
127
- "#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
128
- 'errors, resource limits and skips excluded'
129
- end}"
128
+ # The share covers scored mutants only, so an error-dominated run
129
+ # must not print it as a bare score: "score 100.0 %" above "the run
130
+ # tested almost nothing" reads as a contradiction (#557).
131
+ if report.majority_errored?
132
+ @out.puts "score n/a (#{breakdown.tested} of #{report.executed.size} mutants scored, " \
133
+ "#{percent(breakdown.value)} of scored)"
134
+ else
135
+ @out.puts "score #{percent(breakdown.value)}" \
136
+ "#{if @verbose
137
+ "#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
138
+ 'errors, resource limits and skips excluded'
139
+ end}"
140
+ end
130
141
  report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
131
142
  reused = report.results.count(&:reused?)
132
143
  @out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
@@ -233,6 +244,7 @@ module Saboteur
233
244
  label = stop.reason == "fail_fast" ? "--fail-fast" : "--max-duration spent"
234
245
  @out.puts @palette.paint(:yellow, "stopped early (#{label}): #{early_stop_summary(stop)}; the score below " \
235
246
  "covers only completed work")
247
+ budget_shortfall(stop, report.results.size)
236
248
  list(stop.unreached) { |id| " not run: #{id}" }
237
249
  list(stop.truncated) { |entry| " stopped mid-way: #{entry[:subject]} (#{entry[:omitted]} mutants omitted)" }
238
250
  list(stop.unverified) { |id| " unverified: #{id}" }
@@ -243,6 +255,17 @@ module Saboteur
243
255
  @out.puts " ... #{entries.size - MAX_ERRORS} more" if entries.size > MAX_ERRORS
244
256
  end
245
257
 
258
+ def budget_shortfall(stop, completed)
259
+ return unless stop.reason == "max_duration"
260
+
261
+ line = if stop.planned_mutants
262
+ "partial: #{stop.planned_mutants - completed} of #{stop.planned_mutants} mutants not run"
263
+ else
264
+ "partial: mutant count unknown; the budget was spent before planning finished"
265
+ end
266
+ @out.puts @palette.paint(:yellow, line)
267
+ end
268
+
246
269
  def early_stop_summary(stop)
247
270
  parts = []
248
271
  if stop.unreached.any?
@@ -264,10 +287,20 @@ module Saboteur
264
287
  end
265
288
 
266
289
  def errors(report)
267
- errored = report.results.select { |result| result.status == :error }
290
+ # Rejected kills are verdicts, not tool errors: the tests failed,
291
+ # but they also fail with the method unchanged (#375).
292
+ rejected, errored = report.results.select { |result| result.status == :error }.partition(&:rejected_kill?)
293
+ unless rejected.empty?
294
+ @out.puts "\nRejected kills (tests also fail with the method unchanged; not counted as killed):"
295
+ print_error_lines(rejected)
296
+ end
268
297
  return if errored.empty?
269
298
 
270
299
  @out.puts "\nErrors (not counted as killed):"
300
+ print_error_lines(errored)
301
+ end
302
+
303
+ def print_error_lines(errored)
271
304
  errored.first(MAX_ERRORS).each do |result|
272
305
  lines = result.reason.to_s.lines.map(&:strip)
273
306
  @out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest}: #{lines.first}"
@@ -23,6 +23,11 @@ module Saboteur
23
23
  attr_reader :subject, :mutation, :status, :reason, :runtime, :killed_by, :covered_by, :mutated_source,
24
24
  :kill_cause, :executed
25
25
  attr_accessor :ignored
26
+ # The ignore list's triage for this survivor (#155): a state from
27
+ # IgnoreList::STATES and its reason. accepted_equivalent and wont_fix
28
+ # suppress the exit failure (the survivor still counts in the score); a
29
+ # todo survivor keeps counting as an unignored survivor.
30
+ attr_accessor :triage_state, :triage_reason
26
31
 
27
32
  def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
28
33
  covered_by: [], kill_cause: nil, reused: false, rejected_kill: false, executed: nil)
@@ -41,6 +46,9 @@ module Saboteur
41
46
  @rejected_kill = rejected_kill
42
47
  @reused = reused
43
48
  @ignored = nil
49
+ # triage_state/triage_reason stay unset until the ignore list writes
50
+ # them: the accessors read as nil before that, and there is no
51
+ # statement for a mutation to remove (#304 dogfood).
44
52
  end
45
53
 
46
54
  # The same mutant, marked unverified: the budget ran out before its
@@ -117,7 +125,11 @@ module Saboteur
117
125
  # subjects that started but stopped mid-way as {subject:, omitted:}
118
126
  # entries; unverified lists the subjects whose kills the budget left
119
127
  # unchecked. The verdict covers only completed, verified work.
120
- EarlyStop = Data.define(:reason, :unreached, :truncated, :unverified)
128
+ EarlyStop = Data.define(:reason, :unreached, :truncated, :unverified, :planned_mutants) do
129
+ def initialize(reason:, unreached:, truncated:, unverified:, planned_mutants: nil)
130
+ super
131
+ end
132
+ end
121
133
 
122
134
  # Everything one run produced.
123
135
  class Report
@@ -127,14 +139,14 @@ module Saboteur
127
139
 
128
140
  attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects,
129
141
  :generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions,
130
- :operator_policy, :discovery_failures
142
+ :operator_policy, :discovery_failures, :memory, :shard
131
143
 
132
144
  # REVIEW: diff mode; the terminal then shows one survivor per line.
133
145
  # seed: the random seed every test run started from (nil when unknown).
134
146
  def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
135
147
  seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
136
148
  run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [],
137
- operator_policy: nil, discovery_failures: [])
149
+ operator_policy: nil, discovery_failures: [], memory: nil, shard: nil)
138
150
  @results = results
139
151
  @subjects = subjects
140
152
  @wall_time = wall_time
@@ -154,6 +166,10 @@ module Saboteur
154
166
  @inline_suppressions = inline_suppressions.dup.freeze
155
167
  @operator_policy = operator_policy
156
168
  @discovery_failures = discovery_failures.dup.freeze
169
+ # Per-worker and supervisor peak RSS in MB, when the platform measured
170
+ # it (#202); nil elsewhere, so JSON reports omit the section.
171
+ @memory = memory.dup.freeze
172
+ @shard = shard
157
173
  end
158
174
 
159
175
  # A --matrix run: every covering test ran per mutant.