saboteur 0.2.0 → 0.4.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+
7
+ module Saboteur
8
+ module MutationSource
9
+ # Deletes one association from a hash literal or keyword argument list.
10
+ class PairRemoval < Base
11
+ BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
12
+ Prism::ParametersNode, Prism::BlockParametersNode].freeze
13
+
14
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
15
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
16
+ end
17
+
18
+ def name
19
+ "pair_removal"
20
+ end
21
+
22
+ def mutations_for(subject)
23
+ found = []
24
+ tokens = Prism.lex(subject.file_source).value.map(&:first)
25
+ visit(subject.node.body, subject, found, tokens)
26
+ found
27
+ end
28
+
29
+ private
30
+
31
+ def visit(node, subject, found, tokens)
32
+ return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
33
+
34
+ if node.is_a?(Prism::StatementsNode)
35
+ node.body.each do |child|
36
+ visit(child, subject, found, tokens) unless @arid.any? { |p| p.match?(child.slice) }
37
+ end
38
+ return
39
+ end
40
+ remove_pairs(node, subject, found, tokens) if node.is_a?(Prism::HashNode)
41
+ remove_keywords(node, subject, found, tokens) if node.is_a?(Prism::CallNode)
42
+ node.compact_child_nodes.each { |child| visit(child, subject, found, tokens) }
43
+ end
44
+
45
+ def remove_keywords(node, subject, found, tokens)
46
+ return unless node.arguments
47
+
48
+ node.arguments.arguments.grep(Prism::KeywordHashNode).each do |hash|
49
+ remove_pairs(hash, subject, found, tokens, call: node)
50
+ end
51
+ end
52
+
53
+ def remove_pairs(node, subject, found, tokens, call: nil)
54
+ pairs = node.elements
55
+ pairs.each_with_index do |pair, index|
56
+ next unless pair.is_a?(Prism::AssocNode)
57
+ next if heredoc?(pair)
58
+
59
+ edits = [delete(subject, pair.location)]
60
+ separator = separator_for(node, pairs, index, tokens, call)
61
+ edits << delete(subject, separator) if separator
62
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
63
+ end
64
+ end
65
+
66
+ def separator_for(node, pairs, index, tokens, call)
67
+ pair = pairs[index]
68
+ ending = ending_for(node, call)
69
+ after = comma_between(pair.location.end_offset, ending, tokens)
70
+ return after if after
71
+
72
+ previous = pairs[0...index].last
73
+ previous ||= call.arguments.arguments[0...-1].last if call
74
+ comma_between(previous.location.end_offset, pair.location.start_offset, tokens) if previous
75
+ end
76
+
77
+ def ending_for(node, call)
78
+ return node.closing_loc.start_offset if node.is_a?(Prism::HashNode)
79
+ return call.closing_loc.start_offset if call.closing_loc
80
+ return call.block.location.start_offset if call.block
81
+
82
+ node.location.end_offset
83
+ end
84
+
85
+ def comma_between(first, last, tokens)
86
+ tokens.find do |token|
87
+ token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
88
+ end&.location
89
+ end
90
+
91
+ def heredoc?(node)
92
+ (node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
93
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
94
+ end
95
+
96
+ def delete(subject, location)
97
+ Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # Resolves one exact digest from current source, before caps, ignore suppression or incremental reuse.
6
+ class Replay
7
+ def initialize(digest:)
8
+ unless digest.match?(/\A[0-9a-f]{16}\z/)
9
+ raise ConfigError, "--id needs a full 16 lowercase hexadecimal mutant digest"
10
+ end
11
+
12
+ @digest = digest
13
+ end
14
+
15
+ def select(subjects, candidates, scope: nil)
16
+ match = nil
17
+ subjects.each do |subject|
18
+ list = candidates.for(subject)
19
+ list = scope.restrict(subject, list) if scope
20
+ list.each do |mutation, text|
21
+ digest = mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
22
+ next unless digest == @digest
23
+ raise ConfigError, "mutant #{@digest} is ambiguous; narrow --subject or include files" if match
24
+
25
+ match = [subject, [mutation, text]]
26
+ end
27
+ end
28
+ raise ConfigError, "mutant #{@digest} not found in the selected current source" unless match
29
+
30
+ match
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,87 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "agent_summary"
6
+ require_relative "agent_commands"
7
+
8
+ module Saboteur
9
+ module Reporter
10
+ # A small JSON summary for an AI agent that runs saboteur (`saboteur run --agent`).
11
+ #
12
+ # The payload stays under BYTE_BUDGET: it never embeds file sources or the full
13
+ # Stryker report. It names the verdict, says plainly what was not covered, shows
14
+ # the first few findings with short edit snippets, counts the rest, and points
15
+ # at the commands and files that hold the full detail.
16
+ #
17
+ # Exit codes in this mode are stricter than the classic ones: 0 means the run
18
+ # was complete and clean, nothing less.
19
+ module Agent
20
+ VERSION = 1
21
+ BYTE_BUDGET = 8 * 1024
22
+ FINDING_LIMIT = 3
23
+ SNIPPET_LIMITS = [200, 80, 0].freeze
24
+ MAX_NUMBER_DIGITS = 20
25
+ WARNING_LIMIT = 3
26
+ WARNING_LENGTH = 160
27
+
28
+ EXIT_CLEAN = 0
29
+ EXIT_REVIEW = 1
30
+ EXIT_INVOCATION = 2
31
+ EXIT_INCOMPLETE = 3
32
+
33
+ # Where a verdict comes from: the exact selection and the state of the working
34
+ # source before the run started. subjects, tests and sources are arrays.
35
+ Provenance = Data.define(:ref, :seed, :head, :working_tree, :changed_files, :mode, :json_report, :subjects,
36
+ :tests, :sources, :config_path, :digest, :cache, :incremental, :settings)
37
+
38
+ class << self
39
+ def call(report, provenance)
40
+ fit(report, provenance)
41
+ end
42
+
43
+ def render(payload)
44
+ "#{JSON.pretty_generate(payload)}\n"
45
+ end
46
+
47
+ def exit_code(payload)
48
+ payload.fetch(:exitCode)
49
+ end
50
+
51
+ # The payload for a run that never started or could not finish.
52
+ def failure(message, exit_code)
53
+ verdict = exit_code == EXIT_INVOCATION ? "invalid_invocation" : "tool_failure"
54
+ { version: VERSION, status: "error", verdict: verdict, exitCode: exit_code,
55
+ message: message.to_s[0, 500], next: { help: "saboteur help run" } }
56
+ end
57
+
58
+ private
59
+
60
+ def fit(report, provenance)
61
+ FINDING_LIMIT.downto(0) do |shown|
62
+ SNIPPET_LIMITS.each do |snippet_limit|
63
+ payload = Summary.build(report, provenance, shown, snippet_limit)
64
+ return payload if render(payload).bytesize <= BYTE_BUDGET
65
+ end
66
+ end
67
+ bounded_last_resort(report, provenance)
68
+ end
69
+
70
+ # The minimal payload, or four fixed keys when even that is over the budget.
71
+ def bounded_last_resort(report, provenance)
72
+ small = minimal(report, provenance)
73
+ return small if render(small).bytesize <= BYTE_BUDGET
74
+
75
+ small.slice(:version, :status, :verdict, :exitCode).merge(truncated: true)
76
+ end
77
+
78
+ # The last resort: fixed keys and short clipped values, so it always fits.
79
+ def minimal(report, provenance)
80
+ small = Summary.build(report, provenance, 0, SNIPPET_LIMITS.last)
81
+ small.merge(warnings: [], next: {}, provenance: small[:provenance].slice(:seed, :workingTree, :mode),
82
+ incomplete: small[:incomplete].map { |reason| reason.slice(:reason, :count) })
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,29 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ module Agent
7
+ # Keeps display values short so the agent payload stays inside its byte budget.
8
+ module Clipping
9
+ # A seed or setting stays a number while it is short; a huge value cannot take the budget.
10
+ def bounded_number(value)
11
+ value.to_s.length <= MAX_NUMBER_DIGITS ? value : clip_value(value, MAX_NUMBER_DIGITS)
12
+ end
13
+
14
+ def clip_value(value, limit)
15
+ return nil if value.nil?
16
+
17
+ text = value.to_s
18
+ text.length > limit ? "#{text[0, limit]}..." : text
19
+ end
20
+
21
+ def clip(text, limit)
22
+ return "" if limit.zero?
23
+
24
+ text.length > limit ? "#{text[0, limit]}..." : text
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "shellwords"
5
+
6
+ module Saboteur
7
+ module Reporter
8
+ module Agent
9
+ # The shell commands an agent is told to run next. Every word is escaped with
10
+ # Shellwords, so a ref, path or subject holding shell syntax stays one literal
11
+ # argument. A command is never cut short: when it is longer than MAX_BYTES it
12
+ # is left out, because half a command could run something else.
13
+ module Commands
14
+ MAX_BYTES = 1024
15
+
16
+ # Options typed on the command line that change how a run behaves.
17
+ SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
18
+ timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html" }.freeze
19
+
20
+ class << self
21
+ # Run the same selection again: ref, subjects, sources, tests, config,
22
+ # cache mode and replay digest, with the recorded seed.
23
+ def rerun(provenance)
24
+ words = ["saboteur", "run", "--agent", *selection(provenance), "--seed", provenance.seed.to_s]
25
+ finish(words, provenance)
26
+ end
27
+
28
+ # Replay one survivor by digest in the same selection.
29
+ def replay(provenance, digest)
30
+ words = ["saboteur", "run", "--id", digest, *selection(provenance, replay: true), "--seed",
31
+ provenance.seed.to_s]
32
+ finish(words, provenance)
33
+ end
34
+
35
+ private
36
+
37
+ def finish(words, provenance)
38
+ words += ["--", *provenance.sources] unless provenance.sources.empty?
39
+ command = Shellwords.join(words)
40
+ command.bytesize <= MAX_BYTES ? command : nil
41
+ end
42
+
43
+ def selection(provenance, replay: false)
44
+ words = []
45
+ words.push("--config", provenance.config_path) if provenance.config_path
46
+ words.push("--since", provenance.ref) if provenance.ref
47
+ 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
52
+ words << "--no-cache" if provenance.cache == false
53
+ words << "--incremental" if provenance.incremental
54
+ words.push("--id", provenance.digest) if provenance.digest && !replay
55
+ words
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ module Agent
7
+ # What makes a verdict cover less than the whole selection. Each reason has a
8
+ # stable code, a count and a plain meaning.
9
+ module Incomplete
10
+ class << self
11
+ def reasons(report)
12
+ reasons = []
13
+ add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
14
+ add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
15
+ add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
16
+ add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
17
+ reasons
18
+ end
19
+
20
+ private
21
+
22
+ def add(reasons, code, count, meaning)
23
+ reasons << { reason: code, count: count, meaning: meaning } if count.positive?
24
+ end
25
+
26
+ def skipped_for_coverage(report)
27
+ report.skipped_subjects.size + report.counts[:skipped]
28
+ end
29
+
30
+ def omitted_by_cap(report)
31
+ report.warnings.sum { |warning| warning[/omitted (\d+) mutant/, 1].to_i }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "findings"
5
+ require_relative "agent_incomplete"
6
+ require_relative "agent_clipping"
7
+
8
+ module Saboteur
9
+ module Reporter
10
+ module Agent
11
+ # Builds the payload hash for one run: status, verdict, exit code, counts,
12
+ # provenance, a few findings and the next commands.
13
+ module Summary
14
+ class << self
15
+ include Clipping
16
+
17
+ # Public entry first; the helpers below are private.
18
+ def build(report, provenance, shown, snippet_limit)
19
+ incomplete = Incomplete.reasons(report)
20
+ status = status_of(report, incomplete)
21
+ verdict = verdict_of(report, status)
22
+ {
23
+ version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
24
+ provenance: provenance_hash(provenance),
25
+ counts: counts(report), score: score(report),
26
+ scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
27
+ incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
28
+ findings: findings(report, shown, snippet_limit),
29
+ warnings: warnings(report),
30
+ next: next_steps(report, provenance)
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def status_of(report, incomplete)
37
+ return "not_applicable" if report.results.empty? && report.skipped_subjects.empty?
38
+
39
+ incomplete.empty? ? "complete" : "partial"
40
+ end
41
+
42
+ def verdict_of(report, status)
43
+ return "survivors" if report.unignored_survivors.any?
44
+
45
+ { "complete" => "clean", "partial" => "incomplete", "not_applicable" => "not_applicable" }.fetch(status)
46
+ end
47
+
48
+ def exit_code_for(verdict)
49
+ case verdict
50
+ when "clean" then EXIT_CLEAN
51
+ when "survivors" then EXIT_REVIEW
52
+ else EXIT_INCOMPLETE
53
+ end
54
+ end
55
+
56
+ def counts(report)
57
+ report.counts.transform_keys { |status| camel(status) }.merge(executed: report.executed.size)
58
+ end
59
+
60
+ def camel(status)
61
+ status.to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }.to_sym
62
+ end
63
+
64
+ def score(report)
65
+ report.score&.round(4)
66
+ end
67
+
68
+ # Display values are clipped; the commands under next use the exact values or are left out.
69
+ def provenance_hash(provenance)
70
+ { ref: clip_value(provenance.ref, 120), seed: bounded_number(provenance.seed),
71
+ head: clip_value(provenance.head, 64),
72
+ workingTree: provenance.working_tree, changedFiles: provenance.changed_files,
73
+ mode: provenance.mode, selection: selection_hash(provenance) }
74
+ end
75
+
76
+ def selection_hash(provenance)
77
+ %i[subjects tests sources].to_h do |key|
78
+ list = provenance.public_send(key)
79
+ [key, { count: list.size, first: list.first(3).map { |entry| clip_value(entry, 120) } }]
80
+ end.merge(config: clip_value(provenance.config_path, 120), digest: provenance.digest,
81
+ settings: provenance.settings.transform_values { |value| clip_value(value, 120) })
82
+ end
83
+
84
+ def findings(report, shown, snippet_limit)
85
+ all = Findings.build(report)
86
+ live = report.unignored_survivors.size
87
+ {
88
+ survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
89
+ omitted: live - [shown, all[:survivors].size].min,
90
+ accepted: report.survivors.size - live, ordering: all[:ordering]
91
+ }
92
+ end
93
+
94
+ def shown_findings(listed, shown, snippet_limit)
95
+ listed.first(shown).map do |finding|
96
+ finding.slice(:digest, :line, :endLine, :operator, :coarse)
97
+ .merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
98
+ original: clip(finding[:original], snippet_limit),
99
+ replacement: clip(finding[:replacement], snippet_limit))
100
+ end
101
+ end
102
+
103
+ def warnings(report)
104
+ report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] }
105
+ end
106
+
107
+ def next_steps(report, provenance)
108
+ steps = {}
109
+ add_command(steps, :rerun, Commands.rerun(provenance))
110
+ first = Findings.build(report)[:survivors].first
111
+ add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
112
+ steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
113
+ steps
114
+ end
115
+
116
+ def short_path?(path)
117
+ path && path.bytesize <= 400
118
+ end
119
+
120
+ def add_command(steps, key, command)
121
+ steps[key] = command
122
+ return if command
123
+
124
+ steps[:"#{key}Unavailable"] =
125
+ "command longer than #{Commands::MAX_BYTES} bytes; rebuild it from provenance"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Findings for a code-review surface such as the saboteur GitHub Action:
7
+ # a bounded list of survivors with everything a note on the pull-request
8
+ # diff needs (file, line, digest, operator, original and replacement).
9
+ # Survivors accepted on the ignore list are listed apart, with their
10
+ # reason. The full, unbounded mutant data stays in the report's files
11
+ # section; survivorsTruncated says how many more findings live only there.
12
+ module Findings
13
+ VERSION = 1
14
+
15
+ # GitHub shows at most 10 annotations of one kind per step.
16
+ LIMIT = 10
17
+
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"
24
+
25
+ class << self
26
+ def build(report)
27
+ live, accepted = report.survivors.partition { |result| result.ignored.nil? }
28
+ {
29
+ version: VERSION,
30
+ ordering: ORDERING,
31
+ limit: LIMIT,
32
+ survivors: listed(report, live),
33
+ survivorsTruncated: truncated(live),
34
+ accepted: listed(report, accepted),
35
+ acceptedTruncated: truncated(accepted)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def listed(report, results)
42
+ ordered(report, results).first(LIMIT).map { |result| finding(report, result) }
43
+ end
44
+
45
+ def truncated(results)
46
+ [results.size - LIMIT, 0].max
47
+ end
48
+
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
+ 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)
63
+ end
64
+
65
+ def finding(report, result)
66
+ entries = edit_entries(result)
67
+ entry = {
68
+ digest: result.digest,
69
+ path: path(report, result),
70
+ line: entries.map { |edit| edit[:line] }.min,
71
+ endLine: entries.map { |edit| edit[:endLine] }.max,
72
+ subject: result.subject.id,
73
+ operator: result.mutation.operator,
74
+ original: joined_texts(result) { |edit| original_text(result, edit) },
75
+ replacement: joined_texts(result, &:replacement),
76
+ edits: entries,
77
+ coarse: result.mutation.coarse
78
+ }
79
+ entry = with_changed_lines(report, result, entry)
80
+ result.ignored ? entry.merge(reason: result.ignored) : entry
81
+ end
82
+
83
+ # In diff mode, the changed lines the mutant touches. line above is where
84
+ # the mutated statement starts, which can be above the changed line.
85
+ def with_changed_lines(report, result, entry)
86
+ lines = report.changed_lines_for(result)
87
+ lines.empty? ? entry : entry.merge(changedLines: lines)
88
+ end
89
+
90
+ # Subject#apply applies edits in start-byte order; findings use the
91
+ # same order, so an unsorted edit list from a custom mutation source
92
+ # still gets a sane range and one entry per edit.
93
+ def sorted_edits(result)
94
+ result.mutation.edits.sort_by(&:start_byte)
95
+ end
96
+
97
+ # Stryker joins a multi-edit mutant's replacements the same way, so
98
+ # both reports describe one mutant with one text.
99
+ def joined_texts(result, &pick)
100
+ sorted_edits(result).map { |edit| safe_text(pick.call(edit)) }.join(" | ")
101
+ end
102
+
103
+ # One entry per edit, so a multi-edit finding can point each joined
104
+ # text at its own spot on the diff.
105
+ def edit_entries(result)
106
+ sorted_edits(result).map do |edit|
107
+ {
108
+ line: result.subject.line_of(edit.start_byte),
109
+ endLine: result.subject.line_of([edit.end_byte - 1, edit.start_byte].max),
110
+ original: safe_text(original_text(result, edit)),
111
+ replacement: safe_text(edit.replacement)
112
+ }
113
+ end
114
+ end
115
+
116
+ def original_text(result, edit)
117
+ result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
118
+ end
119
+
120
+ # A byte slice of source may be invalid UTF-8, which JSON cannot hold.
121
+ def safe_text(text)
122
+ text.dup.force_encoding(Encoding::UTF_8).scrub
123
+ end
124
+
125
+ def path(report, result)
126
+ result.subject.file.delete_prefix("#{report.root}/")
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end