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
@@ -28,6 +28,8 @@ module Saboteur
28
28
 
29
29
  return EXIT_TOOL if untested_partial?(report)
30
30
 
31
+ return EXIT_TOOL if unverifiable_scope?(report)
32
+
31
33
  return EXIT_TOOL if mostly_errored?(report)
32
34
 
33
35
  return EXIT_TOOL if incomplete_under_threshold?(report)
@@ -53,6 +55,32 @@ module Saboteur
53
55
  true
54
56
  end
55
57
 
58
+ # A run that verified none of the change under review - every
59
+ # subject skipped (refinements, no coverage, ..., #574) or every
60
+ # mutant unreachable (a suite re-loading mutated files, #597) -
61
+ # must not exit 0 with score n/a and green-light an untested change.
62
+ def unverifiable_scope?(report)
63
+ if unverifiable_review?(report)
64
+ report.skipped_subjects.each do |subject, reason|
65
+ @err.puts("saboteur: #{subject.id} was skipped (#{reason}), so the change under test was not verified")
66
+ end
67
+ return true
68
+ end
69
+ return false unless unverifiable_unreachable?(report)
70
+
71
+ @err.puts("saboteur: all mutants ran unreachable, so the change under test was not verified")
72
+ true
73
+ end
74
+
75
+ def unverifiable_review?(report)
76
+ report.review? && report.subjects.any? &&
77
+ report.subjects.all? { |subject| report.skipped_subjects.key?(subject) }
78
+ end
79
+
80
+ def unverifiable_unreachable?(report)
81
+ report.executed.any? && report.executed.all? { |result| result.status == :unreachable }
82
+ end
83
+
56
84
  def gates = (@gates ||= Gates.new(config))
57
85
 
58
86
  def mostly_errored?(report)
@@ -89,8 +117,11 @@ module Saboteur
89
117
  else
90
118
  if report.majority_errored?
91
119
  "errorMajority"
120
+ elsif unverifiable_review?(report) || unverifiable_unreachable?(report) ||
121
+ (report.early_stop && report.score_breakdown.tested.zero?)
122
+ "incomplete"
92
123
  else
93
- report.early_stop && report.score_breakdown.tested.zero? ? "incomplete" : "incompleteUnderThreshold"
124
+ "incompleteUnderThreshold"
94
125
  end
95
126
  end
96
127
  end
@@ -9,7 +9,8 @@ module Saboteur
9
9
  def option_lines
10
10
  run = parser.summarize
11
11
  { "run" => run, "pr" => run, "audit" => run, "doctor" => doctor_parser.summarize,
12
- "baseline" => baseline_parser.summarize }
12
+ "baseline" => baseline_parser.summarize, "badge" => badge_parser.summarize,
13
+ "trend" => trend_parser.summarize }
13
14
  end
14
15
  end
15
16
  end
@@ -0,0 +1,135 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../reporter/history_reader"
5
+
6
+ module Saboteur
7
+ class CLI
8
+ # `saboteur badge` and `saboteur trend` (#474): a local README badge and
9
+ # a trend read-out over the opt-in score history, no hosted service.
10
+ module HistoryCommand
11
+ DEFAULT_HIGH = 80.0
12
+ DEFAULT_LOW = 60.0
13
+
14
+ def badge_parser
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: saboteur badge [--history PATH] [--output PATH] [--mark-partial] " \
17
+ "[--high PERCENT] [--low PERCENT]"
18
+ opts.on("--history PATH", "Score history JSONL (default: c.score_history)") { |v| @history_path = v }
19
+ opts.on("--output PATH", "Badge JSON destination (default: stdout)") { |v| @output = v }
20
+ opts.on("--mark-partial", "Badge an incomplete run, labeled as partial") { @mark_partial = true }
21
+ opts.on("--high PERCENT", Float, "brightgreen at or above (default #{DEFAULT_HIGH.to_i})") { |v| @high = v }
22
+ opts.on("--low PERCENT", Float, "yellow at or above (default #{DEFAULT_LOW.to_i})") { |v| @low = v }
23
+ end
24
+ end
25
+
26
+ def trend_parser
27
+ OptionParser.new do |opts|
28
+ opts.banner = "Usage: saboteur trend [--history PATH]"
29
+ opts.on("--history PATH", "Score history JSONL (default: c.score_history)") { |v| @history_path = v }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def history_command(command) = command == "badge" ? badge : trend
36
+
37
+ def badge
38
+ badge_parser.parse!(@argv)
39
+ raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
40
+
41
+ write_badge(badge_payload(read_history.last))
42
+ EXIT_OK
43
+ rescue ConfigError, Error, SystemCallError => e
44
+ history_failure(e)
45
+ end
46
+
47
+ def trend
48
+ trend_parser.parse!(@argv)
49
+ raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
50
+
51
+ print_trend(latest_pair)
52
+ EXIT_OK
53
+ rescue ConfigError, Error, SystemCallError => e
54
+ history_failure(e)
55
+ end
56
+
57
+ def history_failure(error)
58
+ @err.puts("saboteur: #{error.message}")
59
+ error.is_a?(ConfigError) ? EXIT_USAGE : EXIT_TOOL
60
+ end
61
+
62
+ def latest_pair
63
+ records = read_history
64
+ current = records.last
65
+ [records[0...-1].reverse.find { |record| Reporter::HistoryReader.comparable?(record, current) }, current]
66
+ end
67
+
68
+ def read_history
69
+ records = Reporter::HistoryReader.read(history_path)
70
+ if records.empty?
71
+ raise Error,
72
+ "score history is empty - record it first by running saboteur with c.score_history set"
73
+ end
74
+
75
+ records
76
+ end
77
+
78
+ def history_path
79
+ @history_path ||= config.path("score_history")
80
+ @history_path or raise UsageError, "no score history: pass --history PATH or set c.score_history"
81
+ rescue ConfigError
82
+ raise UsageError, "no score history: pass --history PATH or set c.score_history"
83
+ end
84
+
85
+ def badge_payload(record)
86
+ partial = record["incomplete"].any?
87
+ if partial && !@mark_partial
88
+ raise Error, "the latest score history record is an incomplete run " \
89
+ "(#{record['incomplete'].map(&:inspect).join(', ')}) - " \
90
+ "badge evidence needs a complete run, or pass --mark-partial to label it"
91
+ end
92
+ score = record["coveredScore"]
93
+ raise Error, "the latest score history record has no tested mutants" if score.nil?
94
+
95
+ { schemaVersion: 1, label: partial ? "mutation score (partial)" : "mutation score",
96
+ message: format("%.1f%%", score * 100), color: badge_color(score) }
97
+ end
98
+
99
+ def write_badge(payload)
100
+ json = "#{JSON.generate(payload)}\n"
101
+ return @out.print(json) unless @output
102
+
103
+ File.write(@output, json)
104
+ @out.puts(@output)
105
+ end
106
+
107
+ def badge_color(score)
108
+ percent = score * 100
109
+ return "brightgreen" if percent >= (@high || DEFAULT_HIGH)
110
+ return "yellow" if percent >= (@low || DEFAULT_LOW)
111
+
112
+ "red"
113
+ end
114
+
115
+ def print_trend((previous, current))
116
+ ([previous].compact + [current]).each { |record| @out.puts(trend_line(record)) }
117
+ unless previous && previous["coveredScore"] && current["coveredScore"]
118
+ return @out.puts("score history: no comparable earlier record (different selection/inventory, " \
119
+ "operator policy or an incomplete run)")
120
+ end
121
+
122
+ delta = (current["coveredScore"] - previous["coveredScore"]) * 100
123
+ @out.puts(format("covered score change %<delta>+.2f percentage points since %<since>s " \
124
+ "(same configured selection)", delta: delta, since: previous["date"]))
125
+ end
126
+
127
+ def trend_line(record)
128
+ suffix = record["incomplete"].empty? ? "" : " (incomplete)"
129
+ score = record["coveredScore"]
130
+ "#{record['date']} #{record['commit']&.slice(0, 8) || '-'} " \
131
+ "#{score.nil? ? 'no tested mutants' : format('%.1f%%', score * 100)}#{suffix}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -4,18 +4,22 @@
4
4
  module Saboteur
5
5
  class CLI
6
6
  # Opinionated bundles (#244): pr is the fast changed-lines loop (one
7
- # baseline check, a 10-minute budget that does not fail the build),
7
+ # baseline check, a 10-minute budget that fails an incomplete run),
8
8
  # audit is the whole project with both reports. Preset flags go first,
9
9
  # so an explicit flag after them wins on the usual last-flag rule.
10
10
  module Presets
11
11
  PRESETS = {
12
- "pr" => %w[--baseline-passes 1 --max-duration 10m --max-duration-ok],
12
+ "pr" => %w[--baseline-passes 1 --max-duration 10m],
13
13
  "audit" => %w[--all --operators all --baseline-passes 2 --json .saboteur/audit-report.json
14
14
  --html .saboteur/audit-report.html]
15
15
  }.freeze
16
16
 
17
17
  private
18
18
 
19
+ def preset_or_usage_error(command)
20
+ PRESETS.key?(command) ? preset(command) : usage_error
21
+ end
22
+
19
23
  def preset(name)
20
24
  flags = PRESETS.fetch(name).dup
21
25
  if name == "pr"
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The run's subject-selection flags: operator sets, single-mutant replay,
7
+ # --since scoping, --dry-run, the flaky-check confirmation rerun (#476)
8
+ # and explicit --subject filters. Grouped so the CLI stays under the
9
+ # house size caps.
10
+ module SelectionOptions
11
+ private
12
+
13
+ def define_selection_options(opts)
14
+ opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
15
+ set(:operators, v)
16
+ end
17
+ opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
18
+ set(:exclude_operators, v)
19
+ end
20
+ opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
21
+ opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
22
+ opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
23
+ opts.on("--shard K/N", "Run one of N deterministic candidate shards (for example 1/4)") { |v| set(:shard, v) }
24
+ opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
25
+ flaky_desc = "Confirm a fresh verdict (survivors, decisive) with one reordered rerun"
26
+ opts.on("--flaky-check MODE", %w[off survivors decisive], flaky_desc) { |v| set(:flaky_check, v) }
27
+ opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Validates the deterministic candidate-shard selection before the target boots.
7
+ module ShardOptions
8
+ private
9
+
10
+ def validate_shard_options
11
+ raise UsageError, "--id cannot be combined with --shard" if options[:id] && config["shard"]
12
+
13
+ Engine::Shard.parse(config["shard"]) if config["shard"]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The effective source set a run will mutate, refused when it is empty.
7
+ module SourceSet
8
+ private
9
+
10
+ # The Rails-shaped footgun (#447): the default include targets plain
11
+ # gems, so a first run on an app/ tree selects nothing and would exit 0
12
+ # over 0 subjects - a green gate that mutates nothing, every CI run.
13
+ # Refuse like a typo'd source: exit 2, name the stale pattern and the
14
+ # way out. --dry-run still previews: it gates nothing.
15
+ def refuse_empty_source_set
16
+ return unless config.included_files.empty?
17
+
18
+ raise UsageError, empty_source_set_message
19
+ end
20
+
21
+ # A --since ref whose resolved range diffs empty yields 0 subjects
22
+ # and a green run over nothing: `saboteur pr <merge_sha>` on a
23
+ # checkout AT the merge diffs the merge against itself (#643).
24
+ # Refuse like a typo'd ref: exit 2, name the base and the range.
25
+ def refuse_empty_since_range
26
+ ref = config["since"]
27
+ return unless ref
28
+
29
+ paths = Selection::GitDiff.changed_paths(ref, root: config.root)
30
+ return unless paths.values.all?(&:empty?)
31
+
32
+ base = Selection::GitDiff.merge_base(ref, root: config.root)
33
+ raise UsageError, "--since #{ref}: the resolved range #{base}..the working tree has no changes - " \
34
+ "the run would pass with 0 subjects. A checkout AT the ref (the CI merge-sha " \
35
+ "checkout) diffs it against itself: fetch the merge's parent, or point --since " \
36
+ "at the base of the change under test"
37
+ end
38
+
39
+ def empty_source_set_message
40
+ include_patterns = config["include"]
41
+ matched = include_patterns.flat_map { |pattern| Dir.glob(pattern, base: config.root) }
42
+ .select { |file| file.end_with?(".rb") }
43
+ cause = if matched.empty?
44
+ "include: #{include_patterns.join(', ')} selects no Ruby file"
45
+ else
46
+ "exclude: #{config['exclude'].join(', ')} removes every included file"
47
+ end
48
+ "#{cause} - the run would pass with 0 subjects. Point include at your code " \
49
+ "(Rails apps: bundle exec rails generate saboteur:install writes the config) or fix the patterns"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,157 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module Saboteur
7
+ class CLI
8
+ # saboteur survivors <report.json> (#475): a read-only, line-oriented
9
+ # terminal browser for the Stryker JSON report. It never boots the
10
+ # project or reruns tests; it lists survivors, filters them, and for
11
+ # one digest prints the diff, the exact replay command and the
12
+ # ignore-file template. It never edits the ignore file.
13
+ module SurvivorsCommand
14
+ private
15
+
16
+ def survivors_command
17
+ survivors_command_inner
18
+ rescue UsageError => e
19
+ @err.puts("saboteur: #{e.message}")
20
+ EXIT_USAGE
21
+ end
22
+
23
+ def survivors_command_inner
24
+ parser = OptionParser.new do |opts|
25
+ opts.on("--ranked", "Review-priority order: the report's ranked findings first")
26
+ opts.on("--file SUBSTR", "Only survivors whose path contains SUBSTR")
27
+ opts.on("--operator NAME", "Only survivors of operator NAME")
28
+ opts.on("--triage LABEL", "Only survivors classified LABEL (for example missing_test)")
29
+ opts.on("--show DIGEST", "Diff, replay command and ignore template for one survivor")
30
+ end
31
+ options = {}
32
+ args = parser.parse!(@argv, into: options)
33
+ path = args.shift or raise UsageError,
34
+ "survivors expects a report path: saboteur survivors .saboteur/report.json"
35
+ raise UsageError, "unexpected arguments: #{args.join(' ')}" if args.any?
36
+
37
+ report = load_survivor_report(path)
38
+ browser = SurvivorsBrowser.new(report)
39
+ if options[:show]
40
+ @out.puts(browser.show(options[:show]))
41
+ else
42
+ @out.puts(browser.list(**options.slice(:ranked, :file, :operator, :triage)))
43
+ end
44
+ EXIT_OK
45
+ end
46
+
47
+ def load_survivor_report(path)
48
+ raise UsageError, "report not found: #{path}" unless File.file?(path)
49
+
50
+ JSON.parse(File.read(path))
51
+ rescue JSON::ParserError => e
52
+ raise UsageError, "#{path} is not a readable saboteur report: #{e.message}"
53
+ end
54
+ end
55
+
56
+ # Reads the report's files section (the complete mutant list), not the
57
+ # capped findings, so survivors beyond the findings limit still list.
58
+ class SurvivorsBrowser
59
+ def initialize(report)
60
+ @report = report
61
+ @findings = report.dig("saboteur", "findings") || {}
62
+ end
63
+
64
+ def list(ranked: false, file: nil, operator: nil, triage: nil)
65
+ survivors = filtered(file, operator, triage)
66
+ survivors = ranked_order(survivors) if ranked
67
+ return "no survivors#{filters_note(file, operator, triage)}" if survivors.empty?
68
+
69
+ [header(survivors.size, file, operator, triage), *rows(survivors)]
70
+ end
71
+
72
+ def show(digest)
73
+ survivor = all_survivors.find { |s| s[:digest].start_with?(digest) } ||
74
+ raise(UsageError, "no survivor with digest #{digest} in this report")
75
+ finding = findings_by_digest[survivor[:digest]] || {}
76
+ subject = finding["subject"]
77
+ [
78
+ "#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
79
+ "",
80
+ "- #{finding['original'] || original_line(survivor)}",
81
+ "+ #{finding['replacement'] || survivor[:replacement]}",
82
+ "",
83
+ "Replay exactly this mutant:",
84
+ " bundle exec saboteur run --id #{survivor[:digest]}",
85
+ "",
86
+ "Ignore-file entry (config/saboteur_ignore.rb):",
87
+ *ignore_entry(survivor, subject)
88
+ ].join("\n")
89
+ end
90
+
91
+ private
92
+
93
+ def filtered(file, operator, triage)
94
+ survivors = all_survivors
95
+ survivors = survivors.select { |s| s[:path].include?(file) } if file
96
+ survivors = survivors.select { |s| s[:operator] == operator } if operator
97
+ triage ? survivors.select { |s| s[:classification] == triage } : survivors
98
+ end
99
+
100
+ def header(size, file, operator, triage)
101
+ text = "#{size} survivor#{'s' unless size == 1}#{filters_note(file, operator, triage)}"
102
+ truncated = @findings["survivorsTruncated"].to_i
103
+ return text unless truncated.positive?
104
+
105
+ "#{text} (the report's ranked findings name only the first #{@findings['limit'] || 10})"
106
+ end
107
+
108
+ def rows(survivors)
109
+ survivors.each_with_index.map do |s, i|
110
+ format("%3<i>d. %<path>s:%<line>d %-28<operator>s %<digest>s %<classification>s",
111
+ i: i + 1, path: s[:path], line: s[:line], operator: s[:operator], digest: s[:digest],
112
+ classification: s[:classification] || "-")
113
+ end
114
+ end
115
+
116
+ def ignore_entry(survivor, subject)
117
+ note = subject ? subject.inspect : '"Klass#meth" # TODO: capped findings omit this digest - fill it in'
118
+ [%( i.mutation "#{survivor[:digest]}",), " subject: #{note},", %( reason: "...")]
119
+ end
120
+
121
+ def all_survivors
122
+ @all_survivors ||= begin
123
+ survivors = @report.fetch("files", {}).flat_map do |path, entry|
124
+ entry.fetch("mutants", []).select { |m| m["status"] == "Survived" }.map do |m|
125
+ { path: path, line: m.dig("location", "start", "line").to_i, operator: m["mutatorName"],
126
+ digest: m["id"], replacement: m["replacement"], classification: m["classification"],
127
+ covered: m["coveredBy"] || [], source: entry["source"] }
128
+ end
129
+ end
130
+ survivors.sort_by { |s| [s[:path], s[:line], s[:digest]] }
131
+ end
132
+ end
133
+
134
+ def ranked_order(survivors)
135
+ rank = @findings.fetch("survivors", []).each_with_index.to_h { |f, i| [f["digest"], i] }
136
+ survivors.sort_by { |s| [rank[s[:digest]] || Float::INFINITY, -s[:covered].size, s[:path], s[:line], s[:digest]] }
137
+ end
138
+
139
+ def findings_by_digest
140
+ @findings_by_digest ||=
141
+ (@findings.fetch("survivors", []) + @findings.fetch("accepted", [])).to_h { |f| [f["digest"], f] }
142
+ end
143
+
144
+ def original_line(survivor)
145
+ survivor[:source].to_s.lines[survivor[:line] - 1].to_s.strip
146
+ end
147
+
148
+ def filters_note(file, operator, triage)
149
+ parts = []
150
+ parts << "file~#{file}" if file
151
+ parts << "operator=#{operator}" if operator
152
+ parts << "triage=#{triage}" if triage
153
+ parts.empty? ? "" : " (#{parts.join(', ')})"
154
+ end
155
+ end
156
+ end
157
+ end