evilution 0.22.7 → 0.24.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +8 -0
  3. data/CHANGELOG.md +28 -0
  4. data/README.md +37 -7
  5. data/lib/evilution/cli/command.rb +37 -0
  6. data/lib/evilution/cli/commands/environment_show.rb +20 -0
  7. data/lib/evilution/cli/commands/init.rb +24 -0
  8. data/lib/evilution/cli/commands/mcp.rb +19 -0
  9. data/lib/evilution/cli/commands/run.rb +68 -0
  10. data/lib/evilution/cli/commands/session_diff.rb +30 -0
  11. data/lib/evilution/cli/commands/session_gc.rb +46 -0
  12. data/lib/evilution/cli/commands/session_list.rb +51 -0
  13. data/lib/evilution/cli/commands/session_show.rb +27 -0
  14. data/lib/evilution/cli/commands/subjects.rb +50 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +43 -0
  16. data/lib/evilution/cli/commands/util_mutation.rb +66 -0
  17. data/lib/evilution/cli/commands/version.rb +17 -0
  18. data/lib/evilution/cli/commands.rb +4 -0
  19. data/lib/evilution/cli/dispatcher.rb +23 -0
  20. data/lib/evilution/cli/parsed_args.rb +12 -0
  21. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  22. data/lib/evilution/cli/parser/file_args.rb +41 -0
  23. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  24. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  25. data/lib/evilution/cli/parser.rb +88 -0
  26. data/lib/evilution/cli/printers/environment.rb +53 -0
  27. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  28. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  29. data/lib/evilution/cli/printers/session_list.rb +48 -0
  30. data/lib/evilution/cli/printers/subjects.rb +35 -0
  31. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  32. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  33. data/lib/evilution/cli/printers.rb +4 -0
  34. data/lib/evilution/cli/result.rb +9 -0
  35. data/lib/evilution/cli.rb +30 -850
  36. data/lib/evilution/config.rb +31 -3
  37. data/lib/evilution/integration/base.rb +23 -55
  38. data/lib/evilution/integration/minitest.rb +22 -4
  39. data/lib/evilution/integration/rspec.rb +28 -8
  40. data/lib/evilution/isolation/fork.rb +11 -9
  41. data/lib/evilution/isolation/in_process.rb +11 -9
  42. data/lib/evilution/mcp/info_tool.rb +261 -0
  43. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  44. data/lib/evilution/mcp/server.rb +3 -4
  45. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  46. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  47. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  48. data/lib/evilution/mcp/session_tool.rb +157 -0
  49. data/lib/evilution/reporter/cli.rb +2 -1
  50. data/lib/evilution/reporter/html/assets/style.css +68 -0
  51. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  52. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  53. data/lib/evilution/reporter/html/escape.rb +12 -0
  54. data/lib/evilution/reporter/html/namespace.rb +11 -0
  55. data/lib/evilution/reporter/html/report.rb +68 -0
  56. data/lib/evilution/reporter/html/section.rb +21 -0
  57. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  58. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  59. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  60. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  61. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  62. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  63. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  64. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  65. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  66. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  67. data/lib/evilution/reporter/html/sections.rb +4 -0
  68. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  69. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  70. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  71. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  72. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  73. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  74. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  75. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  76. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  77. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  78. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  79. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  80. data/lib/evilution/reporter/html.rb +11 -349
  81. data/lib/evilution/reporter/json.rb +12 -8
  82. data/lib/evilution/result/mutation_result.rb +5 -1
  83. data/lib/evilution/result/summary.rb +9 -1
  84. data/lib/evilution/runner/baseline_runner.rb +71 -0
  85. data/lib/evilution/runner/diagnostics.rb +105 -0
  86. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  87. data/lib/evilution/runner/mutation_executor.rb +255 -0
  88. data/lib/evilution/runner/mutation_planner.rb +126 -0
  89. data/lib/evilution/runner/report_publisher.rb +60 -0
  90. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  91. data/lib/evilution/runner.rb +57 -692
  92. data/lib/evilution/version.rb +1 -1
  93. metadata +71 -2
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::CLI::Dispatcher
4
+ @commands = {}
5
+
6
+ class << self
7
+ def register(symbol, klass)
8
+ @commands[symbol] = klass
9
+ end
10
+
11
+ def lookup(symbol)
12
+ @commands.fetch(symbol) { raise KeyError, "unknown command: #{symbol.inspect}" }
13
+ end
14
+
15
+ def registered?(symbol)
16
+ @commands.key?(symbol)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :commands
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::CLI
4
+ ParsedArgs = Struct.new(
5
+ :command, :options, :files, :line_ranges, :stdin_error, :parse_error,
6
+ keyword_init: true
7
+ ) do
8
+ def initialize(command:, options: {}, files: [], line_ranges: {}, stdin_error: nil, parse_error: nil)
9
+ super
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::CLI::Parser::CommandExtractor
4
+ SIMPLE_COMMANDS = {
5
+ "version" => :version,
6
+ "init" => :init,
7
+ "mcp" => :mcp,
8
+ "subjects" => :subjects
9
+ }.freeze
10
+
11
+ SESSION_SUBCOMMANDS = {
12
+ "list" => :session_list,
13
+ "show" => :session_show,
14
+ "diff" => :session_diff,
15
+ "gc" => :session_gc
16
+ }.freeze
17
+
18
+ TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
19
+ ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
20
+ UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
21
+
22
+ Result = Struct.new(:command, :remaining_argv, :parse_error)
23
+
24
+ def self.call(argv)
25
+ new(argv).call
26
+ end
27
+
28
+ def initialize(argv)
29
+ @argv = argv.dup
30
+ @command = :run
31
+ @parse_error = nil
32
+ end
33
+
34
+ def call
35
+ extract
36
+ Result.new(@command, @argv, @parse_error)
37
+ end
38
+
39
+ private
40
+
41
+ def extract
42
+ first = @argv.first
43
+ if SIMPLE_COMMANDS.key?(first)
44
+ @command = SIMPLE_COMMANDS[first]
45
+ @argv.shift
46
+ elsif first == "run"
47
+ @argv.shift
48
+ elsif first == "session"
49
+ @argv.shift
50
+ extract_subcommand(SESSION_SUBCOMMANDS, "session", "list, show, diff, gc")
51
+ elsif first == "tests"
52
+ @argv.shift
53
+ extract_subcommand(TESTS_SUBCOMMANDS, "tests", "list")
54
+ elsif first == "environment"
55
+ @argv.shift
56
+ extract_subcommand(ENVIRONMENT_SUBCOMMANDS, "environment", "show")
57
+ elsif first == "util"
58
+ @argv.shift
59
+ extract_subcommand(UTIL_SUBCOMMANDS, "util", "mutation")
60
+ end
61
+ end
62
+
63
+ def extract_subcommand(table, family, available)
64
+ sub = @argv.first
65
+ if table.key?(sub)
66
+ @command = table[sub]
67
+ @argv.shift
68
+ elsif sub.nil?
69
+ @command = :parse_error
70
+ @parse_error = "Missing #{family} subcommand. Available subcommands: #{available}"
71
+ else
72
+ @command = :parse_error
73
+ @parse_error = "Unknown #{family} subcommand: #{sub}. Available subcommands: #{available}"
74
+ @argv.shift
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::CLI::Parser::FileArgs
4
+ module_function
5
+
6
+ def parse(raw_args)
7
+ files = []
8
+ ranges = {}
9
+
10
+ raw_args.each do |arg|
11
+ file, range_str = arg.split(":", 2)
12
+ files << file
13
+ next unless range_str
14
+
15
+ ranges[file] = parse_line_range(range_str)
16
+ end
17
+
18
+ [files, ranges]
19
+ end
20
+
21
+ def expand_spec_dir(dir)
22
+ unless File.directory?(dir)
23
+ warn("Error: #{dir} is not a directory")
24
+ return []
25
+ end
26
+
27
+ Dir.glob(File.join(dir, "**/*_spec.rb"))
28
+ end
29
+
30
+ def parse_line_range(str)
31
+ if str.include?("-")
32
+ start_str, end_str = str.split("-", 2)
33
+ start_line = Integer(start_str)
34
+ end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
35
+ start_line..end_line
36
+ else
37
+ line = Integer(str)
38
+ line..line
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../../version"
5
+ require_relative "file_args"
6
+
7
+ class Evilution::CLI::Parser::OptionsBuilder
8
+ def self.build(options)
9
+ new(options).build
10
+ end
11
+
12
+ def initialize(options)
13
+ @options = options
14
+ end
15
+
16
+ def build
17
+ OptionParser.new do |opts|
18
+ opts.banner = "Usage: evilution [command] [options] [files...]"
19
+ opts.version = Evilution::VERSION
20
+ add_separators(opts)
21
+ add_core_options(opts)
22
+ add_filter_options(opts)
23
+ add_flag_options(opts)
24
+ add_extra_flag_options(opts)
25
+ add_session_options(opts)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def add_separators(opts)
32
+ opts.separator ""
33
+ opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
34
+ opts.separator ""
35
+ opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
36
+ opts.separator " util {mutation}, environment {show}, mcp, version"
37
+ opts.separator ""
38
+ opts.separator "Options:"
39
+ end
40
+
41
+ def add_core_options(opts)
42
+ opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
43
+ opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
44
+ opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
45
+ end
46
+
47
+ def add_filter_options(opts)
48
+ opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
49
+ opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
50
+ opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
51
+ opts.on("--target EXPR",
52
+ "Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
53
+ "class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
54
+ @options[:target] = m
55
+ end
56
+ end
57
+
58
+ def add_flag_options(opts)
59
+ opts.on("--fail-fast", "Stop after N surviving mutants " \
60
+ "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
61
+ opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
62
+ opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
63
+ opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
64
+ opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
65
+ opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
66
+ "(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
67
+ opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
68
+ opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
69
+ opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
70
+ opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
71
+ end
72
+
73
+ def add_extra_flag_options(opts)
74
+ opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
75
+ opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
76
+ @options[:related_specs_heuristic] = true
77
+ end
78
+ opts.on("--fallback-full-suite", "Run the whole test suite when no matching spec/test resolves " \
79
+ "for a mutation (default: mark the mutation :unresolved and skip)") do
80
+ @options[:fallback_to_full_suite] = true
81
+ end
82
+ opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
83
+ opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
84
+ opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
85
+ opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
86
+ opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
87
+ opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
88
+ end
89
+
90
+ def add_session_options(opts)
91
+ opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
92
+ opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
93
+ opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
94
+ opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
95
+ @options[:older_than] = d
96
+ end
97
+ end
98
+
99
+ def expand_spec_dir(dir)
100
+ specs = Evilution::CLI::Parser::FileArgs.expand_spec_dir(dir)
101
+ @options[:spec_files] = Array(@options[:spec_files]) + specs
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_args"
4
+
5
+ class Evilution::CLI::Parser::StdinReader
6
+ Result = Struct.new(:files, :ranges, :error)
7
+
8
+ def self.call(io, existing_files:)
9
+ new(io, existing_files: existing_files).call
10
+ end
11
+
12
+ def initialize(io, existing_files:)
13
+ @io = io
14
+ @existing_files = existing_files
15
+ end
16
+
17
+ def call
18
+ return Result.new([], {}, "--stdin cannot be combined with positional file arguments") if @existing_files.any?
19
+
20
+ lines = []
21
+ @io.each_line do |line|
22
+ line = line.strip
23
+ lines << line unless line.empty?
24
+ end
25
+ files, ranges = Evilution::CLI::Parser::FileArgs.parse(lines)
26
+ Result.new(files, ranges, nil)
27
+ end
28
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parsed_args"
4
+
5
+ class Evilution::CLI::Parser
6
+ def initialize(argv, stdin: $stdin)
7
+ @argv = argv.dup
8
+ @stdin = stdin
9
+ @options = {}
10
+ @files = []
11
+ @line_ranges = {}
12
+ @command = :run
13
+ @parse_error = nil
14
+ @stdin_error = nil
15
+ end
16
+
17
+ def parse
18
+ extract_command
19
+ return build_parsed_args if @command == :parse_error
20
+
21
+ preprocess_flags
22
+ remaining = OptionsBuilder.build(@options).parse!(@argv)
23
+ @files, @line_ranges = FileArgs.parse(remaining)
24
+ read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
25
+ build_parsed_args
26
+ end
27
+
28
+ private
29
+
30
+ def extract_command
31
+ result = CommandExtractor.call(@argv)
32
+ @command = result.command
33
+ @argv = result.remaining_argv
34
+ @parse_error = result.parse_error
35
+ end
36
+
37
+ def preprocess_flags
38
+ result = []
39
+ i = 0
40
+ while i < @argv.length
41
+ arg = @argv[i]
42
+ if arg == "--fail-fast"
43
+ next_arg = @argv[i + 1]
44
+
45
+ if next_arg && next_arg.match?(/\A-?\d+\z/)
46
+ @options[:fail_fast] = next_arg
47
+ i += 2
48
+ else
49
+ result << arg
50
+ i += 1
51
+ end
52
+ elsif arg.start_with?("--fail-fast=")
53
+ @options[:fail_fast] = arg.delete_prefix("--fail-fast=")
54
+ i += 1
55
+ else
56
+ result << arg
57
+ i += 1
58
+ end
59
+ end
60
+ @argv = result
61
+ end
62
+
63
+ def read_stdin_files
64
+ result = StdinReader.call(@stdin, existing_files: @files)
65
+ if result.error
66
+ @stdin_error = result.error
67
+ return
68
+ end
69
+ @files = result.files
70
+ @line_ranges = @line_ranges.merge(result.ranges)
71
+ end
72
+
73
+ def build_parsed_args
74
+ Evilution::CLI::ParsedArgs.new(
75
+ command: @command,
76
+ options: @options,
77
+ files: @files,
78
+ line_ranges: @line_ranges,
79
+ stdin_error: @stdin_error,
80
+ parse_error: @parse_error
81
+ )
82
+ end
83
+ end
84
+
85
+ require_relative "parser/command_extractor"
86
+ require_relative "parser/file_args"
87
+ require_relative "parser/stdin_reader"
88
+ require_relative "parser/options_builder"
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../printers"
4
+
5
+ class Evilution::CLI::Printers::Environment
6
+ def initialize(config, config_file:)
7
+ @config = config
8
+ @config_file = config_file
9
+ end
10
+
11
+ def render(io)
12
+ lines = header_lines
13
+ lines.concat(settings_lines)
14
+ io.puts(lines.join("\n"))
15
+ end
16
+
17
+ private
18
+
19
+ def header_lines
20
+ [
21
+ "Evilution Environment",
22
+ ("=" * 30),
23
+ "",
24
+ "evilution: #{Evilution::VERSION}",
25
+ "ruby: #{RUBY_VERSION}",
26
+ "config_file: #{@config_file || "(none)"}",
27
+ "",
28
+ "Settings:"
29
+ ]
30
+ end
31
+
32
+ def settings_lines
33
+ [
34
+ " timeout: #{@config.timeout}",
35
+ " format: #{@config.format}",
36
+ " integration: #{@config.integration}",
37
+ " jobs: #{@config.jobs}",
38
+ " isolation: #{@config.isolation}",
39
+ " baseline: #{@config.baseline}",
40
+ " incremental: #{@config.incremental}",
41
+ " verbose: #{@config.verbose}",
42
+ " quiet: #{@config.quiet}",
43
+ " progress: #{@config.progress}",
44
+ " fail_fast: #{@config.fail_fast || "(disabled)"}",
45
+ " min_score: #{@config.min_score}",
46
+ " suggest_tests: #{@config.suggest_tests}",
47
+ " save_session: #{@config.save_session}",
48
+ " target: #{@config.target || "(all files)"}",
49
+ " skip_heredoc_literals: #{@config.skip_heredoc_literals}",
50
+ " ignore_patterns: #{@config.ignore_patterns.empty? ? "(none)" : @config.ignore_patterns.inspect}"
51
+ ]
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../printers"
5
+
6
+ class Evilution::CLI::Printers::SessionDetail
7
+ def initialize(data, format:)
8
+ @data = data
9
+ @format = format
10
+ end
11
+
12
+ def render(io)
13
+ @format == :json ? render_json(io) : render_text(io)
14
+ end
15
+
16
+ private
17
+
18
+ def render_json(io)
19
+ io.puts(JSON.pretty_generate(@data))
20
+ end
21
+
22
+ def render_text(io)
23
+ print_header(io, @data)
24
+ print_summary(io, @data["summary"])
25
+ print_survived(io, @data["survived"] || [])
26
+ end
27
+
28
+ def print_header(io, data)
29
+ io.puts("Session: #{data["timestamp"]}")
30
+ io.puts("Version: #{data["version"]}")
31
+ print_git_context(io, data["git"])
32
+ end
33
+
34
+ def print_git_context(io, git)
35
+ return unless git.is_a?(Hash)
36
+
37
+ branch = git["branch"]
38
+ sha = git["sha"]
39
+ return if branch.to_s.empty? && sha.to_s.empty?
40
+
41
+ io.puts("Git: #{branch} (#{sha})")
42
+ end
43
+
44
+ def print_summary(io, summary)
45
+ io.puts("")
46
+ io.puts(
47
+ format(
48
+ "Score: %<score>.2f%% Total: %<total>d Killed: %<killed>d Survived: %<surv>d " \
49
+ "Timed out: %<to>d Errors: %<err>d Duration: %<dur>.1fs",
50
+ score: summary["score"] * 100, total: summary["total"], killed: summary["killed"],
51
+ surv: summary["survived"], to: summary["timed_out"], err: summary["errors"],
52
+ dur: summary["duration"]
53
+ )
54
+ )
55
+ end
56
+
57
+ def print_survived(io, survived)
58
+ io.puts("")
59
+ if survived.empty?
60
+ io.puts("No survived mutations")
61
+ else
62
+ io.puts("Survived mutations (#{survived.length}):")
63
+ survived.each_with_index { |m, i| print_mutation_detail(io, m, i + 1) }
64
+ end
65
+ end
66
+
67
+ def print_mutation_detail(io, mutation, index)
68
+ io.puts("")
69
+ io.puts(" #{index}. #{mutation["operator"]} — #{mutation["file"]}:#{mutation["line"]}")
70
+ io.puts(" Subject: #{mutation["subject"]}")
71
+ return unless mutation["diff"]
72
+
73
+ io.puts(" Diff:")
74
+ mutation["diff"].each_line { |line| io.puts(" #{line.chomp}") }
75
+ end
76
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../printers"
5
+
6
+ class Evilution::CLI::Printers::SessionDiff
7
+ def initialize(result, format:)
8
+ @result = result
9
+ @format = format
10
+ end
11
+
12
+ def render(io)
13
+ @format == :json ? render_json(io) : render_text(io)
14
+ end
15
+
16
+ private
17
+
18
+ def render_json(io)
19
+ io.puts(JSON.pretty_generate(@result.to_h))
20
+ end
21
+
22
+ def render_text(io)
23
+ print_summary(io, @result.summary)
24
+ print_section(io, "Fixed (survived \u2192 killed)", @result.fixed, "\e[32m")
25
+ print_section(io, "New survivors (killed \u2192 survived)", @result.new_survivors, "\e[31m")
26
+ print_section(io, "Persistent survivors", @result.persistent, "\e[33m")
27
+
28
+ return unless @result.fixed.empty? && @result.new_survivors.empty? && @result.persistent.empty?
29
+
30
+ io.puts("")
31
+ io.puts("No mutation changes between sessions")
32
+ end
33
+
34
+ def print_summary(io, summary)
35
+ delta_str = format("%+.2f%%", summary.score_delta * 100)
36
+ io.puts("Session Diff")
37
+ io.puts("=" * 40)
38
+ io.puts(format("Base score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
39
+ score: summary.base_score * 100, killed: summary.base_killed,
40
+ total: summary.base_total))
41
+ io.puts(format("Head score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
42
+ score: summary.head_score * 100, killed: summary.head_killed,
43
+ total: summary.head_total))
44
+ io.puts("Delta: #{delta_str}")
45
+ end
46
+
47
+ def print_section(io, title, mutations, color)
48
+ return if mutations.empty?
49
+
50
+ reset = "\e[0m"
51
+ io.puts("")
52
+ io.puts("#{color}#{title} (#{mutations.length}):#{reset}")
53
+ mutations.each do |m|
54
+ io.puts(" #{m["operator"]} \u2014 #{m["file"]}:#{m["line"]} #{m["subject"]}")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../printers"
5
+
6
+ class Evilution::CLI::Printers::SessionList
7
+ def initialize(sessions, format:)
8
+ @sessions = sessions
9
+ @format = format
10
+ end
11
+
12
+ def render(io)
13
+ @format == :json ? render_json(io) : render_text(io)
14
+ end
15
+
16
+ private
17
+
18
+ def render_json(io)
19
+ io.puts(JSON.pretty_generate(@sessions.map { |s| session_to_hash(s) }))
20
+ end
21
+
22
+ def render_text(io)
23
+ header = "Timestamp Total Killed Surv. Score Duration"
24
+ io.puts(header)
25
+ io.puts("-" * header.length)
26
+ @sessions.each { |s| io.puts(format_row(s)) }
27
+ end
28
+
29
+ def format_row(session)
30
+ format(
31
+ "%-30<ts>s %6<total>d %6<killed>d %6<surv>d %7.2<score>f%% %7.1<dur>fs",
32
+ ts: session[:timestamp], total: session[:total], killed: session[:killed],
33
+ surv: session[:survived], score: session[:score] * 100, dur: session[:duration]
34
+ )
35
+ end
36
+
37
+ def session_to_hash(session)
38
+ {
39
+ "timestamp" => session[:timestamp],
40
+ "total" => session[:total],
41
+ "killed" => session[:killed],
42
+ "survived" => session[:survived],
43
+ "score" => session[:score],
44
+ "duration" => session[:duration],
45
+ "file" => session[:file]
46
+ }
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../printers"
4
+
5
+ class Evilution::CLI::Printers::Subjects
6
+ def initialize(entries, total_mutations:)
7
+ @entries = entries
8
+ @total_mutations = total_mutations
9
+ end
10
+
11
+ def render(io)
12
+ @entries.each { |entry| io.puts(format_entry(entry)) }
13
+ io.puts("")
14
+ io.puts(summary_line)
15
+ end
16
+
17
+ private
18
+
19
+ def format_entry(entry)
20
+ " #{entry[:name]} #{entry[:file_path]}:#{entry[:line_number]} (#{mutation_label(entry[:mutation_count])})"
21
+ end
22
+
23
+ def mutation_label(count)
24
+ pluralize(count, "mutation", "mutations")
25
+ end
26
+
27
+ def summary_line
28
+ "#{pluralize(@entries.length, "subject", "subjects")}, " \
29
+ "#{pluralize(@total_mutations, "mutation", "mutations")}"
30
+ end
31
+
32
+ def pluralize(count, singular, plural)
33
+ "#{count} #{count == 1 ? singular : plural}"
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../printers"
4
+
5
+ class Evilution::CLI::Printers::TestsList
6
+ def initialize(mode:, specs: nil, entries: nil)
7
+ @mode = mode
8
+ @specs = specs
9
+ @entries = entries
10
+ end
11
+
12
+ def render(io)
13
+ case @mode
14
+ when :explicit then render_explicit(io)
15
+ when :resolved then render_resolved(io)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def render_explicit(io)
22
+ @specs.each { |f| io.puts(" #{f}") }
23
+ io.puts("")
24
+ io.puts(@specs.length == 1 ? "1 spec file" : "#{@specs.length} spec files")
25
+ end
26
+
27
+ def render_resolved(io)
28
+ unique_specs = []
29
+ @entries.each do |entry|
30
+ source = entry[:source]
31
+ spec = entry[:spec]
32
+ if spec
33
+ unique_specs << spec
34
+ io.puts(" #{spec} (#{source})")
35
+ else
36
+ io.puts(" #{source} (no spec found)")
37
+ end
38
+ end
39
+
40
+ unique_specs.uniq!
41
+ io.puts("")
42
+ spec_label = unique_specs.length == 1 ? "1 spec file" : "#{unique_specs.length} spec files"
43
+ io.puts("#{@entries.length} source files, #{spec_label}")
44
+ end
45
+ end