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
@@ -15,7 +15,8 @@ module Saboteur
15
15
 
16
16
  # Options typed on the command line that change how a run behaves.
17
17
  SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
18
- timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html" }.freeze
18
+ timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html",
19
+ operators: "--operators", exclude_operators: "--exclude-operators" }.freeze
19
20
 
20
21
  class << self
21
22
  # Run the same selection again: ref, subjects, sources, tests, config,
@@ -40,15 +41,28 @@ module Saboteur
40
41
  command.bytesize <= MAX_BYTES ? command : nil
41
42
  end
42
43
 
44
+ # An explicit scope is frozen: --no-guess-tests keeps a config-file
45
+ # guess_tests from conflicting with the --test list.
46
+ def test_words(tests)
47
+ tests.empty? ? [] : ["--no-guess-tests", *tests.flat_map { |test| ["--test", test] }]
48
+ end
49
+
50
+ def setting_words(provenance)
51
+ SETTING_FLAGS.flat_map do |key, flag|
52
+ next [] unless provenance.settings.key?(key)
53
+
54
+ value = provenance.settings[key]
55
+ [flag, value.is_a?(Array) ? value.join(",") : value.to_s]
56
+ end
57
+ end
58
+
43
59
  def selection(provenance, replay: false)
44
60
  words = []
45
61
  words.push("--config", provenance.config_path) if provenance.config_path
46
62
  words.push("--since", provenance.ref) if provenance.ref
47
63
  provenance.subjects.each { |subject| words.push("--subject", subject) }
48
- provenance.tests.each { |test| words.push("--test", test) }
49
- SETTING_FLAGS.each do |key, flag|
50
- words.push(flag, provenance.settings[key].to_s) if provenance.settings.key?(key)
51
- end
64
+ words.push(*test_words(provenance.tests))
65
+ words.concat(setting_words(provenance))
52
66
  words << "--no-cache" if provenance.cache == false
53
67
  words << "--incremental" if provenance.incremental
54
68
  words.push("--id", provenance.digest) if provenance.digest && !replay
@@ -7,24 +7,47 @@ module Saboteur
7
7
  # What makes a verdict cover less than the whole selection. Each reason has a
8
8
  # stable code, a count and a plain meaning.
9
9
  module Incomplete
10
+ NO_COVERAGE = "no coverage"
11
+
10
12
  class << self
11
13
  def reasons(report)
12
14
  reasons = []
13
15
  add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
16
+ load_only = report.counts[:ran_at_load] + report.skipped_subjects.values.count do |reason|
17
+ reason == Selection::CoverageMap::RAN_AT_LOAD
18
+ end
19
+ add(reasons, "unsupported", unsupported_subjects(report),
20
+ "subjects were skipped as unsupported (for example defined inside a block); no mutant was judged")
21
+ add(reasons, "ran_at_load", load_only, "code ran only at load; no mutant was judged")
22
+ add(reasons, "unreachable", report.counts[:unreachable], "tests did not call the replacement definition")
14
23
  add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
15
24
  add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
16
25
  add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
26
+ add(reasons, "fail_fast", report.early_stop&.unreached&.size.to_i,
27
+ "selected subjects did not run because --fail-fast stopped the run")
28
+ inline_scope(reasons, report)
17
29
  reasons
18
30
  end
19
31
 
20
32
  private
21
33
 
34
+ def inline_scope(reasons, report)
35
+ add(reasons, "inline_suppressed", report.inline_suppressions.size,
36
+ "candidates were suppressed by inline directives; see the full report audit")
37
+ end
38
+
22
39
  def add(reasons, code, count, meaning)
23
40
  reasons << { reason: code, count: count, meaning: meaning } if count.positive?
24
41
  end
25
42
 
26
43
  def skipped_for_coverage(report)
27
- report.skipped_subjects.size + report.counts[:skipped]
44
+ report.skipped_subjects.values.count(NO_COVERAGE) + report.counts[:skipped]
45
+ end
46
+
47
+ def unsupported_subjects(report)
48
+ report.skipped_subjects.values.count do |reason|
49
+ reason != NO_COVERAGE && reason != Selection::CoverageMap::RAN_AT_LOAD
50
+ end
28
51
  end
29
52
 
30
53
  def omitted_by_cap(report)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "findings"
5
+ require_relative "survivor_hints"
5
6
  require_relative "agent_incomplete"
6
7
  require_relative "agent_clipping"
7
8
 
@@ -21,12 +22,12 @@ module Saboteur
21
22
  verdict = verdict_of(report, status)
22
23
  {
23
24
  version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
24
- provenance: provenance_hash(provenance),
25
+ provenance: provenance_hash(provenance).merge(operatorPolicy: report.operator_policy),
25
26
  counts: counts(report), score: score(report),
26
27
  scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
27
28
  incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
28
29
  findings: findings(report, shown, snippet_limit),
29
- warnings: warnings(report),
30
+ warnings: report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] },
30
31
  next: next_steps(report, provenance)
31
32
  }
32
33
  end
@@ -34,7 +35,7 @@ module Saboteur
34
35
  private
35
36
 
36
37
  def status_of(report, incomplete)
37
- return "not_applicable" if report.results.empty? && report.skipped_subjects.empty?
38
+ return "not_applicable" if report.results.empty? && incomplete.empty?
38
39
 
39
40
  incomplete.empty? ? "complete" : "partial"
40
41
  end
@@ -87,7 +88,8 @@ module Saboteur
87
88
  {
88
89
  survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
89
90
  omitted: live - [shown, all[:survivors].size].min,
90
- accepted: report.survivors.size - live, ordering: all[:ordering]
91
+ accepted: report.survivors.size - live, ordering: all[:ordering],
92
+ hintCaveat: SurvivorHints::CAVEAT
91
93
  }
92
94
  end
93
95
 
@@ -97,11 +99,15 @@ module Saboteur
97
99
  .merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
98
100
  original: clip(finding[:original], snippet_limit),
99
101
  replacement: clip(finding[:replacement], snippet_limit))
102
+ .merge(finding_hints(finding))
100
103
  end
101
104
  end
102
105
 
103
- def warnings(report)
104
- report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] }
106
+ def finding_hints(finding)
107
+ structured = SurvivorHints.structured(operator: finding[:operator], original: finding[:original],
108
+ replacement: finding[:replacement], coarse: finding[:coarse])
109
+ { hint: SurvivorHints.for(finding[:operator], coarse: finding[:coarse]) }.compact
110
+ .merge(testHint: structured)
105
111
  end
106
112
 
107
113
  def next_steps(report, provenance)
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "survivor_ranking"
5
+
4
6
  module Saboteur
5
7
  module Reporter
6
8
  # Findings for a code-review surface such as the saboteur GitHub Action:
@@ -15,12 +17,7 @@ module Saboteur
15
17
  # GitHub shows at most 10 annotations of one kind per step.
16
18
  LIMIT = 10
17
19
 
18
- # Published ordering, coarse survivors first: a surviving coarse mutant
19
- # replaced the whole method body, which hints that no test pins the
20
- # method's behavior - though it can also be a re-evaluation limit or an
21
- # equivalent mutant, so this is a heuristic, not a guarantee. Ties break
22
- # by file, line and digest, so one report always reads the same way.
23
- ORDERING = "coarse survivors first, then file, line and digest"
20
+ ORDERING = SurvivorRanking::ORDERING
24
21
 
25
22
  class << self
26
23
  def build(report)
@@ -46,20 +43,8 @@ module Saboteur
46
43
  [results.size - LIMIT, 0].max
47
44
  end
48
45
 
49
- # partition keeps the coarse group ahead; inside a group the key is
50
- # total (no two mutants share a digest), so the order is stable.
51
46
  def ordered(report, results)
52
- results.partition { |result| result.mutation.coarse }
53
- .map { |group| group.sort_by { |result| tie_break(report, result) } }
54
- .flatten
55
- end
56
-
57
- def tie_break(report, result)
58
- [path(report, result), first_line(result), result.digest]
59
- end
60
-
61
- def first_line(result)
62
- result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
47
+ SurvivorRanking.ordered(report, results)
63
48
  end
64
49
 
65
50
  def finding(report, result)
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Refuses linked or non-regular history/report destinations before writes.
7
+ module HistoryPaths
8
+ module_function
9
+
10
+ def validate(paths)
11
+ paths = paths.compact.map { |path| File.expand_path(path) }
12
+ identities = paths.map do |path|
13
+ reject_links(path)
14
+ next unless File.exist?(path)
15
+
16
+ stat = File.stat(path)
17
+ raise Error, "score history/report destination must be a regular file" unless stat.file?
18
+
19
+ [stat.dev, stat.ino]
20
+ end.compact
21
+ raise Error, "score history/report destinations alias the same file" unless identities.uniq == identities
22
+ rescue SystemCallError => e
23
+ raise Error, "score history/report paths cannot be verified: #{e.message}"
24
+ end
25
+
26
+ def reject_links(path)
27
+ loop do
28
+ raise Error, "score history/report paths must not contain symlinks" if File.symlink?(path)
29
+
30
+ parent = File.dirname(path)
31
+ break if parent == path
32
+
33
+ path = parent
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # v1 stays readable, but v2 records require versioned operator evidence.
7
+ module HistoryPolicy
8
+ def validate_header(record, fields)
9
+ version = record.is_a?(Hash) && record["version"]
10
+ raise Error, "score history contains an invalid version" unless version.is_a?(Integer) && [1,
11
+ 2].include?(version)
12
+
13
+ fields(record, version == 2 ? fields + ["operatorPolicy"] : fields)
14
+ validate_policy(record["operatorPolicy"]) if version == 2
15
+ end
16
+
17
+ def validate_policy(value)
18
+ raise Error, "score history contains invalid operator policy" unless value.is_a?(Hash)
19
+
20
+ fields(value, %w[version selected excluded effective])
21
+ raise Error, "score history contains invalid operator policy" if value["effective"].empty?
22
+
23
+ lists = value.values_at("selected", "excluded", "effective")
24
+ unless lists.all? { |list| list.is_a?(Array) && list.all?(String) }
25
+ raise Error, "score history contains invalid operator policy"
26
+ end
27
+
28
+ expected = MutationSource::OperatorRegistry.policy(value["selected"], value["excluded"])
29
+ raise Error, "score history contains invalid operator policy" unless value == expected
30
+ rescue ConfigError, NoMethodError, TypeError
31
+ raise Error, "score history contains invalid operator policy"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+ require_relative "history_policy"
7
+
8
+ module Saboteur
9
+ module Reporter
10
+ # Strict version-one observation schema. Never repairs imported evidence.
11
+ module HistoryRecord
12
+ FIELDS = %w[version date commit selection inventory score coveredScore counts breakdown incomplete].freeze
13
+ BREAKDOWN = %w[score tested assertionKills crashKills unclassifiedKills timeouts survivors
14
+ assertionShare crashShare].freeze
15
+ COUNTS = STATUSES.map(&:to_s).freeze
16
+
17
+ class UniqueObject < Hash
18
+ def []=(key, value)
19
+ raise Error, "score history contains duplicate keys" if key?(key)
20
+
21
+ super
22
+ end
23
+ end
24
+
25
+ extend HistoryPolicy
26
+
27
+ module_function
28
+
29
+ def parse(line)
30
+ record = JSON.parse(line, object_class: UniqueObject)
31
+ validate(record)
32
+ record
33
+ rescue JSON::ParserError, ArgumentError, TypeError => e
34
+ raise Error, "score history contains an invalid record: #{e.message}"
35
+ end
36
+
37
+ def validate(record)
38
+ validate_header(record, FIELDS)
39
+
40
+ date(record.fetch("date"))
41
+ commit = record.fetch("commit")
42
+ raise Error, "score history contains an invalid commit" unless commit.nil? || sha?(commit, 40)
43
+
44
+ %w[selection inventory].each do |key|
45
+ raise Error, "score history contains an invalid digest" unless sha?(record.fetch(key), 64)
46
+ end
47
+ %w[score coveredScore].each { |key| fraction(record.fetch(key)) }
48
+ counts = record.fetch("counts")
49
+ fields(counts, COUNTS)
50
+ counts.each_value { |value| count(value) }
51
+ breakdown(record.fetch("breakdown"), counts, record.fetch("coveredScore"))
52
+ score_consistency(record)
53
+ reasons(record.fetch("incomplete"))
54
+ end
55
+
56
+ def date(value)
57
+ return if Time.iso8601(value).utc.iso8601 == value
58
+
59
+ raise Error,
60
+ "score history contains an invalid date"
61
+ rescue ArgumentError, TypeError, NoMethodError => e
62
+ raise Error, "score history contains an invalid date: #{e.message}"
63
+ end
64
+
65
+ def reasons(value)
66
+ unless value.is_a?(Array) && value.all? do |reason|
67
+ reason.is_a?(String) && !reason.empty?
68
+ end && value.uniq == value
69
+ raise Error, "score history contains invalid incomplete reasons"
70
+ end
71
+ end
72
+
73
+ def score_consistency(record)
74
+ value = record.fetch("breakdown")
75
+ detected = value.values_at("assertionKills", "crashKills", "unclassifiedKills", "timeouts").sum
76
+ tested = value.fetch("tested")
77
+ expected = tested.zero? ? nil : detected.fdiv(tested)
78
+ raise Error, "score history contains an inconsistent score" unless record.fetch("coveredScore") == expected
79
+
80
+ %w[assertion crash].each do |kind|
81
+ expected_share = tested.zero? ? nil : value.fetch("#{kind}Kills").fdiv(tested)
82
+ unless value.fetch("#{kind}Share") == expected_share
83
+ raise Error,
84
+ "score history contains an inconsistent share"
85
+ end
86
+ end
87
+ overall = record.fetch("score")
88
+ return unless overall && ((record.fetch("coveredScore").nil? && !overall.zero?) ||
89
+ (record.fetch("coveredScore") && overall > record.fetch("coveredScore")))
90
+
91
+ raise Error, "score history contains an inconsistent overall score"
92
+ end
93
+
94
+ def fields(object, expected)
95
+ return if object.is_a?(Hash) && object.keys.sort == expected.sort
96
+
97
+ raise Error,
98
+ "score history contains an invalid record schema"
99
+ end
100
+
101
+ def sha?(value, length)
102
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{#{length}}\z/)
103
+ end
104
+
105
+ def fraction(value)
106
+ return if value.nil? || (0..1).cover?(value)
107
+
108
+ raise Error,
109
+ "score history contains an invalid score"
110
+ end
111
+
112
+ def count(value)
113
+ raise Error, "score history contains an invalid count" unless value.is_a?(Integer) && value >= 0
114
+ end
115
+
116
+ def breakdown(value, counts, covered)
117
+ fields(value, BREAKDOWN)
118
+ %w[score assertionShare crashShare].each { |key| fraction(value.fetch(key)) }
119
+ (BREAKDOWN - %w[score assertionShare crashShare]).each { |key| count(value.fetch(key)) }
120
+ kills = value.values_at("assertionKills", "crashKills", "unclassifiedKills").sum
121
+ tested = counts.values_at("killed", "survived", "timeout").sum
122
+ unless kills == counts.fetch("killed") && value.fetch("tested") == tested &&
123
+ value.fetch("timeouts") == counts.fetch("timeout") &&
124
+ value.fetch("survivors") == counts.fetch("survived") &&
125
+ value.fetch("score") == covered
126
+ raise Error, "score history contains inconsistent counts"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -57,7 +57,7 @@ module Saboteur
57
57
  <title>saboteur report</title>
58
58
  <style>#{STYLE}</style></head><body>
59
59
  <h1>saboteur report</h1>
60
- #{summary(report, data)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
60
+ #{summary(report, data)}#{inline_suppressions(report)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
61
61
  <h2>Files</h2>
62
62
  #{data[:files].map { |file, entry| file_section(file, entry, names, results) }.join("\n")}
63
63
  </body></html>
@@ -66,6 +66,17 @@ module Saboteur
66
66
 
67
67
  private
68
68
 
69
+ def inline_suppressions(report)
70
+ return "" if report.inline_suppressions.empty?
71
+
72
+ items = report.inline_suppressions.map do |entry|
73
+ path = entry[:file].delete_prefix("#{report.root}/")
74
+ "<li>#{escape(path)}:#{entry[:line]} #{escape(entry[:subject])} " \
75
+ "#{escape(entry[:operator])} <code>#{escape(entry[:digest])}</code>: #{escape(entry[:reason])}</li>"
76
+ end
77
+ "<h2>Inline suppressions (#{items.size}, outside the score)</h2><ul>#{items.join}</ul>"
78
+ end
79
+
69
80
  def summary(report, data)
70
81
  seed = data.dig(:config, :seed)
71
82
  seed_note = seed ? ", seed #{seed}" : ""
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # The original and replacement text of one mutant, and the line it
7
+ # changes, for compact one-line output.
8
+ module MutationText
9
+ module_function
10
+
11
+ def line(result)
12
+ result.subject.line_of(sorted_edits(result).first.start_byte)
13
+ end
14
+
15
+ def original(result)
16
+ join(result) { |edit| result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte) }
17
+ end
18
+
19
+ def replacement(result)
20
+ join(result, &:replacement)
21
+ end
22
+
23
+ def sorted_edits(result)
24
+ result.mutation.edits.sort_by(&:start_byte)
25
+ end
26
+
27
+ # Whole-body mutants carry a long text; one line keeps the output compact.
28
+ def join(result, &pick)
29
+ text = sorted_edits(result).map { |edit| pick.call(edit).to_s.dup.force_encoding(Encoding::UTF_8).scrub }
30
+ .join(" | ")
31
+ flat = text.gsub(/\s*\n\s*/, " ").strip
32
+ flat.length > 60 ? "#{flat[0, 57]}..." : flat
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # The footer under a run that left survivors: how to look at one, how to
7
+ # accept one, and how to hand the run to an agent. Silent when nothing
8
+ # survived, so a clean run stays short.
9
+ class NextSteps
10
+ def initialize(out, palette)
11
+ @out = out
12
+ @palette = palette
13
+ end
14
+
15
+ def call(report)
16
+ survivor = report.unignored_survivors.first
17
+ return unless survivor
18
+
19
+ @out.puts "\n#{@palette.paint(:bold, 'Next steps:')}"
20
+ seed = " --seed #{report.seed}" if report.seed
21
+ @out.puts " replay one survivor: saboteur run --id #{survivor.digest}#{seed}"
22
+ @out.puts " (add this run's own --config, --since, --subject, --test and source options)"
23
+ @out.puts " accept an equivalent one: add to your ignore file (c.ignore_file, default #{Config::IGNORE_FILE}):"
24
+ entry = %(i.mutation "#{survivor.digest}", subject: "#{survivor.subject.id}", reason: "why it is equivalent")
25
+ @out.puts " #{entry}"
26
+ @out.puts " let an agent close the gaps: saboteur run --agent, with the same options"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Wraps text in ANSI colour codes when colour is on, and returns it
7
+ # unchanged when it is off. Colour is on only when asked for or when the
8
+ # output is a terminal and NO_COLOR is not set (https://no-color.org).
9
+ class Palette
10
+ CODES = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36, dim: 2, bold: 1 }.freeze
11
+
12
+ # mode: :auto, :always or :never.
13
+ def self.for(out, mode: :auto, environment: ENV)
14
+ enabled = case mode
15
+ when :always then true
16
+ when :never then false
17
+ else out.respond_to?(:tty?) && out.tty? && environment["NO_COLOR"].to_s.empty?
18
+ end
19
+ new(enabled: enabled)
20
+ end
21
+
22
+ def initialize(enabled:)
23
+ @enabled = enabled
24
+ end
25
+
26
+ def enabled?
27
+ @enabled
28
+ end
29
+
30
+ def paint(color, text)
31
+ return text unless @enabled
32
+
33
+ "\e[#{CODES.fetch(color)}m#{text}\e[0m"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,135 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+ require "digest"
7
+ require "fileutils"
8
+ require_relative "agent_incomplete"
9
+ require_relative "history_record"
10
+
11
+ module Saboteur
12
+ module Reporter
13
+ # Opt-in append-only score observations, not a baseline or a CI gate.
14
+ class ScoreHistory
15
+ DIFFERENT_RUN = "score history: change unavailable (different selection/inventory or incomplete run)"
16
+ DIFFERENT_POLICY = "score history: change unavailable (different record version or operator policy)"
17
+ MISSING_SCORE = "score history: change unavailable (no tested mutants)"
18
+ MAX_RECORD_BYTES = 64 * 1024
19
+ OPEN_FLAGS = File::RDWR | File::CREAT | File::NONBLOCK | File::NOFOLLOW
20
+
21
+ def initialize(path)
22
+ @path = path
23
+ end
24
+
25
+ def append(report, head:, scope:)
26
+ record = build(report, head, scope)
27
+ HistoryRecord.validate(record)
28
+ line = "#{JSON.generate(record)}\n"
29
+ raise Error, "score history record exceeds byte limit" if line.bytesize > MAX_RECORD_BYTES
30
+
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ File.open(@path, OPEN_FLAGS, 0o600) do |file|
33
+ regular_file(file)
34
+ lock(file, File::LOCK_EX)
35
+
36
+ previous = previous_record(file)
37
+ file.seek(0, IO::SEEK_END)
38
+ file.write(line)
39
+ file.flush
40
+ change(previous, record)
41
+ end
42
+ rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError => e
43
+ raise Error, "score history was not recorded: #{e.message}"
44
+ end
45
+
46
+ def validate_existing
47
+ return unless File.exist?(@path) || File.symlink?(@path)
48
+
49
+ File.open(@path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
50
+ regular_file(file)
51
+ lock(file, File::LOCK_SH)
52
+
53
+ previous_record(file)
54
+ end
55
+ rescue SystemCallError, IOError => e
56
+ raise Error, "score history cannot be read: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ def build(report, head, scope)
62
+ { "version" => 2, "date" => Time.now.utc.iso8601, "commit" => head,
63
+ "selection" => Digest::SHA256.hexdigest(JSON.generate(scope.sort_by { |key, _| key.to_s }.to_h)),
64
+ "inventory" => Digest::SHA256.hexdigest(JSON.generate(report.results.map(&:digest).sort)),
65
+ "score" => overall_score(report), "coveredScore" => report.score,
66
+ "counts" => report.counts.transform_keys(&:to_s),
67
+ "breakdown" => report.score_breakdown.to_h.transform_keys(&:to_s),
68
+ "incomplete" => incompleteness(report), "operatorPolicy" => report.operator_policy }
69
+ end
70
+
71
+ def overall_score(report)
72
+ score = report.score_breakdown
73
+ missing = report.results.count { |result| result.status == :skipped && result.reason == "no coverage" }
74
+ denominator = score.tested + missing
75
+ denominator.zero? ? nil : score.detected.fdiv(denominator)
76
+ end
77
+
78
+ def incompleteness(report)
79
+ reasons = Agent::Incomplete.reasons(report).map { |reason| reason[:reason] }
80
+ reasons << "baseline_incomplete" if report.run_evidence && !report.run_evidence["complete"]
81
+ reasons << "omissions" unless report.omissions.empty?
82
+ reasons.uniq
83
+ end
84
+
85
+ def lock(file, mode)
86
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
87
+ until file.flock(mode | File::LOCK_NB)
88
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
89
+ raise Error,
90
+ "score history lock was not acquired"
91
+ end
92
+
93
+ sleep 0.01
94
+ end
95
+ end
96
+
97
+ def regular_file(file)
98
+ raise Error, "score history must be a regular file" unless file.stat.file?
99
+ end
100
+
101
+ def previous_record(file)
102
+ while (line = file.gets(MAX_RECORD_BYTES + 1))
103
+ raise Error, "score history record exceeds byte limit" if line.bytesize > MAX_RECORD_BYTES
104
+ raise Error, "score history contains a truncated record" unless line.end_with?("\n")
105
+
106
+ previous = HistoryRecord.parse(line)
107
+ end
108
+ previous
109
+ end
110
+
111
+ # Only one record version and one non-null operator policy compare:
112
+ # scores never mix across v1/v2 or across policy configurations.
113
+ # A non-null previous policy forces a non-null equal current one, so
114
+ # only the previous record needs the null guard.
115
+ def comparable_policy?(previous, current)
116
+ previous["version"] == current["version"] &&
117
+ !previous["operatorPolicy"].nil? &&
118
+ previous["operatorPolicy"] == current["operatorPolicy"]
119
+ end
120
+
121
+ def change(previous, current)
122
+ return "score history: first record" unless previous
123
+ return DIFFERENT_RUN unless
124
+ previous["selection"] == current["selection"] && previous["inventory"] == current["inventory"] &&
125
+ previous["incomplete"].empty? && current["incomplete"].empty?
126
+ return DIFFERENT_POLICY unless comparable_policy?(previous, current)
127
+ return MISSING_SCORE if
128
+ previous["coveredScore"].nil? || current["coveredScore"].nil?
129
+
130
+ delta = (current["coveredScore"] - previous["coveredScore"]) * 100
131
+ format("score history: covered score change %+.2f percentage points (same configured selection)", delta)
132
+ end
133
+ end
134
+ end
135
+ end