evilution 0.23.0 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +5 -0
  3. data/CHANGELOG.md +16 -0
  4. data/README.md +1 -0
  5. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  6. data/lib/evilution/cli/parser/file_args.rb +41 -0
  7. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  8. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  9. data/lib/evilution/cli/parser.rb +27 -196
  10. data/lib/evilution/config.rb +14 -1
  11. data/lib/evilution/integration/base.rb +11 -57
  12. data/lib/evilution/integration/minitest.rb +16 -3
  13. data/lib/evilution/integration/rspec.rb +19 -7
  14. data/lib/evilution/isolation/fork.rb +1 -0
  15. data/lib/evilution/isolation/in_process.rb +1 -0
  16. data/lib/evilution/reporter/cli.rb +2 -1
  17. data/lib/evilution/reporter/html/assets/style.css +68 -0
  18. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  19. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  20. data/lib/evilution/reporter/html/escape.rb +12 -0
  21. data/lib/evilution/reporter/html/namespace.rb +11 -0
  22. data/lib/evilution/reporter/html/report.rb +68 -0
  23. data/lib/evilution/reporter/html/section.rb +21 -0
  24. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  25. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  26. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  27. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  28. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  29. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  30. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  31. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  32. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  33. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  34. data/lib/evilution/reporter/html/sections.rb +4 -0
  35. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  36. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  37. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  38. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  39. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  40. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  41. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  42. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  43. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  44. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  45. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  46. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  47. data/lib/evilution/reporter/html.rb +11 -390
  48. data/lib/evilution/reporter/json.rb +12 -8
  49. data/lib/evilution/result/mutation_result.rb +5 -1
  50. data/lib/evilution/result/summary.rb +9 -1
  51. data/lib/evilution/runner/baseline_runner.rb +71 -0
  52. data/lib/evilution/runner/diagnostics.rb +105 -0
  53. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  54. data/lib/evilution/runner/mutation_executor.rb +255 -0
  55. data/lib/evilution/runner/mutation_planner.rb +126 -0
  56. data/lib/evilution/runner/report_publisher.rb +60 -0
  57. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  58. data/lib/evilution/runner.rb +57 -694
  59. data/lib/evilution/version.rb +1 -1
  60. metadata +42 -1
@@ -1,10 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "prism"
5
- require "tmpdir"
6
4
  require_relative "../integration"
7
- require_relative "../temp_dir_tracker"
8
5
 
9
6
  class Evilution::Integration::Base
10
7
  def self.baseline_runner
@@ -20,7 +17,6 @@ class Evilution::Integration::Base
20
17
  end
21
18
 
22
19
  def call(mutation)
23
- @temp_dir = nil
24
20
  ensure_framework_loaded
25
21
  fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
26
22
  load_error = apply_mutation(mutation)
@@ -28,8 +24,6 @@ class Evilution::Integration::Base
28
24
 
29
25
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
30
26
  run_tests(mutation)
31
- ensure
32
- restore_original(mutation)
33
27
  end
34
28
 
35
29
  private
@@ -58,15 +52,10 @@ class Evilution::Integration::Base
58
52
  prism_error = validate_mutated_syntax(mutation.mutated_source)
59
53
  return prism_error if prism_error
60
54
 
61
- @temp_dir = Dir.mktmpdir("evilution")
62
- Evilution::TempDirTracker.register(@temp_dir)
63
- @displaced_feature = nil
64
- subpath = resolve_require_subpath(mutation.file_path)
65
-
66
- if subpath
67
- apply_via_require(mutation, subpath)
68
- else
69
- apply_via_load(mutation)
55
+ pin_autoloaded_constants(mutation.original_source)
56
+ clear_concern_state(mutation.file_path)
57
+ with_redefinition_recovery(mutation.original_source) do
58
+ eval_mutated_source(mutation)
70
59
  end
71
60
  nil
72
61
  rescue SyntaxError => e
@@ -96,29 +85,14 @@ class Evilution::Integration::Base
96
85
  }
97
86
  end
98
87
 
99
- def apply_via_require(mutation, subpath)
100
- dest = File.join(@temp_dir, subpath)
101
- FileUtils.mkdir_p(File.dirname(dest))
102
- File.write(dest, mutation.mutated_source)
103
- $LOAD_PATH.unshift(@temp_dir)
104
- displace_loaded_feature(mutation.file_path)
105
- pin_autoloaded_constants(mutation.original_source)
106
- clear_concern_state(mutation.file_path)
107
- with_redefinition_recovery(mutation.original_source) do
108
- require(subpath.delete_suffix(".rb"))
109
- end
110
- end
111
-
112
- def apply_via_load(mutation)
88
+ # Evaluate the mutated source with __FILE__ set to the original path so
89
+ # that `require_relative` and `__dir__` resolve against the real source
90
+ # tree, where sibling files actually exist.
91
+ def eval_mutated_source(mutation)
113
92
  absolute = File.expand_path(mutation.file_path)
114
- dest = File.join(@temp_dir, absolute)
115
- FileUtils.mkdir_p(File.dirname(dest))
116
- File.write(dest, mutation.mutated_source)
117
- pin_autoloaded_constants(mutation.original_source)
118
- clear_concern_state(mutation.file_path)
119
- with_redefinition_recovery(mutation.original_source) do
120
- load(dest)
121
- end
93
+ # rubocop:disable Security/Eval
94
+ eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
95
+ # rubocop:enable Security/Eval
122
96
  end
123
97
 
124
98
  def with_redefinition_recovery(original_source)
@@ -134,18 +108,6 @@ class Evilution::Integration::Base
134
108
  error.message.include?("already defined")
135
109
  end
136
110
 
137
- def restore_original(_mutation)
138
- return unless @temp_dir
139
-
140
- $LOAD_PATH.delete(@temp_dir)
141
- $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
142
- $LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
143
- @displaced_feature = nil
144
- FileUtils.rm_rf(@temp_dir)
145
- Evilution::TempDirTracker.unregister(@temp_dir)
146
- @temp_dir = nil
147
- end
148
-
149
111
  def pin_autoloaded_constants(source)
150
112
  collect_constant_names(Prism.parse(source).value).each do |name|
151
113
  Object.const_get(name) if Object.const_defined?(name, false)
@@ -237,12 +199,4 @@ class Evilution::Integration::Base
237
199
 
238
200
  best_subpath
239
201
  end
240
-
241
- def displace_loaded_feature(file_path)
242
- absolute = File.expand_path(file_path)
243
- return unless $LOADED_FEATURES.include?(absolute)
244
-
245
- @displaced_feature = absolute
246
- $LOADED_FEATURES.delete(absolute)
247
- end
248
202
  end
@@ -35,10 +35,11 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
35
35
  }
36
36
  end
37
37
 
38
- def initialize(test_files: nil, hooks: nil)
38
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false)
39
39
  @test_files = test_files
40
40
  @minitest_loaded = false
41
41
  @spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
42
+ @fallback_to_full_suite = fallback_to_full_suite
42
43
  @crash_detector = nil
43
44
  @warned_files = Set.new
44
45
  super(hooks: hooks)
@@ -62,6 +63,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
62
63
  def run_tests(mutation)
63
64
  reset_state
64
65
  files = resolve_test_files(mutation)
66
+ return unresolved_result(mutation) if files.nil?
67
+
65
68
  command = "ruby -Itest #{files.join(" ")}"
66
69
 
67
70
  files.each { |f| load(File.expand_path(f)) }
@@ -75,6 +78,15 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
75
78
  { passed: false, error: e.message, test_command: command }
76
79
  end
77
80
 
81
+ def unresolved_result(mutation)
82
+ {
83
+ passed: false,
84
+ unresolved: true,
85
+ error: "no matching test resolved for #{mutation.file_path}",
86
+ test_command: "ruby -Itest (skipped: no test resolved for #{mutation.file_path})"
87
+ }
88
+ end
89
+
78
90
  def build_args(_mutation)
79
91
  ["--seed", "0"]
80
92
  end
@@ -129,7 +141,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
129
141
  resolved = @spec_resolver.call(mutation.file_path)
130
142
  unless resolved
131
143
  warn_unresolved_test(mutation.file_path)
132
- return glob_test_files
144
+ return @fallback_to_full_suite ? glob_test_files : nil
133
145
  end
134
146
 
135
147
  [resolved]
@@ -144,7 +156,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
144
156
  return if @warned_files.include?(file_path)
145
157
 
146
158
  @warned_files << file_path
147
- warn "[evilution] No matching test found for #{file_path}, running full suite. " \
159
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
160
+ warn "[evilution] No matching test found for #{file_path}, #{action}. " \
148
161
  "Use --spec to specify the test file."
149
162
  end
150
163
  end
@@ -26,12 +26,13 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
26
26
  { runner: baseline_runner }
27
27
  end
28
28
 
29
- def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false)
29
+ def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false)
30
30
  @test_files = test_files
31
31
  @rspec_loaded = false
32
32
  @spec_resolver = Evilution::SpecResolver.new
33
33
  @related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
34
34
  @related_specs_heuristic_enabled = related_specs_heuristic
35
+ @fallback_to_full_suite = fallback_to_full_suite
35
36
  @crash_detector = nil
36
37
  @warned_files = Set.new
37
38
  super(hooks: hooks)
@@ -57,10 +58,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
57
58
  def run_tests(mutation)
58
59
  reset_state
59
60
 
61
+ files = resolve_test_files(mutation)
62
+ return unresolved_result(mutation) if files.nil?
63
+
60
64
  out = StringIO.new
61
65
  err = StringIO.new
62
- command = "rspec"
63
- args = build_args(mutation)
66
+ args = build_args(files)
64
67
  command = "rspec #{args.join(" ")}"
65
68
 
66
69
  detector = reset_crash_detector
@@ -74,11 +77,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
74
77
  release_rspec_state(eg_before)
75
78
  end
76
79
 
77
- def build_args(mutation)
78
- files = resolve_test_files(mutation)
80
+ def build_args(files)
79
81
  ["--format", "progress", "--no-color", "--order", "defined", *files]
80
82
  end
81
83
 
84
+ def unresolved_result(mutation)
85
+ {
86
+ passed: false,
87
+ unresolved: true,
88
+ error: "no matching spec resolved for #{mutation.file_path}",
89
+ test_command: "rspec (skipped: no spec resolved for #{mutation.file_path})"
90
+ }
91
+ end
92
+
82
93
  def reset_state
83
94
  if ::RSpec.respond_to?(:clear_examples)
84
95
  ::RSpec.clear_examples
@@ -158,7 +169,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
158
169
  resolved = @spec_resolver.call(mutation.file_path)
159
170
  unless resolved
160
171
  warn_unresolved_spec(mutation.file_path)
161
- return ["spec"]
172
+ return @fallback_to_full_suite ? ["spec"] : nil
162
173
  end
163
174
 
164
175
  return [resolved] unless @related_specs_heuristic_enabled
@@ -171,7 +182,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
171
182
  return if @warned_files.include?(file_path)
172
183
 
173
184
  @warned_files << file_path
174
- warn "[evilution] No matching spec found for #{file_path}, running full suite. " \
185
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
186
+ warn "[evilution] No matching spec found for #{file_path}, #{action}. " \
175
187
  "Use --spec to specify the spec file."
176
188
  end
177
189
 
@@ -98,6 +98,7 @@ class Evilution::Isolation::Fork
98
98
  def classify_status(result)
99
99
  return :timeout if result[:timeout]
100
100
  return :killed if result[:test_crashed]
101
+ return :unresolved if result[:unresolved]
101
102
  return :error if result[:error]
102
103
  return :survived if result[:passed]
103
104
 
@@ -65,6 +65,7 @@ class Evilution::Isolation::InProcess
65
65
  def classify_status(result)
66
66
  return :timeout if result[:timeout]
67
67
  return :killed if result[:test_crashed]
68
+ return :unresolved if result[:unresolved]
68
69
  return :error if result[:error]
69
70
  return :survived if result[:passed]
70
71
 
@@ -90,12 +90,13 @@ class Evilution::Reporter::CLI
90
90
  "#{summary.survived} survived, #{summary.timed_out} timed out"
91
91
  parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
92
92
  parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
93
+ parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
93
94
  parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
94
95
  parts
95
96
  end
96
97
 
97
98
  def score_line(summary)
98
- denominator = summary.total - summary.errors - summary.neutral - summary.equivalent
99
+ denominator = summary.total - summary.errors - summary.neutral - summary.equivalent - summary.unresolved
99
100
  score_pct = format_pct(summary.score)
100
101
  "Score: #{score_pct} (#{summary.killed}/#{denominator})"
101
102
  end
@@ -0,0 +1,68 @@
1
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
3
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
4
+ h1 { font-size: 1.5rem; color: #f0f6fc; }
5
+ .version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
6
+ .score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
7
+ .score-high { background: #1a4731; color: #3fb950; }
8
+ .score-medium { background: #4a3a10; color: #d29922; }
9
+ .score-low { background: #4a1a1a; color: #f85149; }
10
+ .summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
11
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
12
+ .card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
13
+ .card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
14
+ .card-killed { border-color: #238636; }
15
+ .card-survived { border-color: #da3633; }
16
+ .truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
17
+ .file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
18
+ .file-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: #1c2129; border-bottom: 1px solid #30363d; font-size: 0.9rem; }
19
+ .file-path { color: #58a6ff; font-family: monospace; }
20
+ .file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
21
+ .mutation-map { padding: 0.5rem 1rem; }
22
+ .map-line { display: flex; align-items: center; gap: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; font-family: monospace; }
23
+ .map-line.killed { color: #3fb950; }
24
+ .map-line.survived { color: #f85149; }
25
+ .map-line.timeout { color: #d29922; }
26
+ .map-line.error { color: #f85149; }
27
+ .map-line.neutral { color: #8b949e; }
28
+ .map-line.equivalent { color: #8b949e; }
29
+ .line-number { min-width: 60px; color: #8b949e; }
30
+ .operator { flex: 1; }
31
+ .status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
32
+ .status-badge.killed { background: #1a4731; }
33
+ .status-badge.survived { background: #4a1a1a; }
34
+ .status-badge.timeout { background: #4a3a10; }
35
+ .status-badge.neutral { background: #21262d; }
36
+ .status-badge.equivalent { background: #21262d; }
37
+ .survived-details { border-top: 1px solid #30363d; padding: 1rem; }
38
+ .survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
39
+ .survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
40
+ .survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
41
+ .survived-header .operator { color: #f85149; font-weight: bold; }
42
+ .survived-header .location { color: #8b949e; font-family: monospace; }
43
+ .diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
44
+ .diff-removed { color: #f85149; display: block; }
45
+ .diff-added { color: #3fb950; display: block; }
46
+ .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
47
+ .coverage-gap { border: 1px solid #30363d; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; background: #161b22; }
48
+ .gap-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; font-size: 0.85rem; padding-bottom: 0.5rem; border-bottom: 1px solid #21262d; }
49
+ .gap-header .location { color: #58a6ff; font-family: monospace; }
50
+ .gap-count { background: #4a1a1a; color: #f85149; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: bold; }
51
+ .operator-tag { background: #21262d; color: #8b949e; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-family: monospace; }
52
+ .empty { color: #8b949e; text-align: center; padding: 2rem; }
53
+ .baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
54
+ .baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
55
+ .comparison-scores { display: flex; gap: 2rem; font-size: 0.9rem; }
56
+ .delta-positive { color: #3fb950; font-weight: bold; }
57
+ .delta-negative { color: #f85149; font-weight: bold; }
58
+ .delta-neutral { color: #8b949e; font-weight: bold; }
59
+ .error-details { border-top: 1px solid #30363d; padding: 1rem; }
60
+ .error-details h3 { color: #d29922; font-size: 0.9rem; margin-bottom: 0.75rem; }
61
+ .error-entry { background: #1c1a10; border: 1px solid #4a3a10; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
62
+ .error-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
63
+ .error-header .operator { color: #d29922; font-weight: bold; }
64
+ .error-header .location { color: #8b949e; font-family: monospace; }
65
+ .error-message { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; color: #f85149; margin-top: 0.5rem; white-space: pre-wrap; overflow-x: auto; }
66
+ .survived-entry.regression { border-color: #f85149; background: #2a1010; }
67
+ .regression-badge { background: #da3633; color: #fff; font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 4px; margin-left: 0.5rem; text-transform: uppercase; font-weight: bold; }
68
+ footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "namespace"
4
+
5
+ class Evilution::Reporter::HTML::BaselineKeys
6
+ def initialize(baseline)
7
+ @keys = build(baseline)
8
+ end
9
+
10
+ def regression?(mutation)
11
+ return false if @keys.nil?
12
+
13
+ !@keys.include?(key_for(mutation))
14
+ end
15
+
16
+ private
17
+
18
+ def build(baseline)
19
+ return nil unless baseline
20
+
21
+ survived = baseline["survived"] || []
22
+ survived.to_set { |m| [m["operator"], m["file"], m["line"], m["subject"]] }
23
+ end
24
+
25
+ def key_for(mutation)
26
+ [mutation.operator_name, mutation.file_path, mutation.line, mutation.subject.name]
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "namespace"
4
+ require_relative "escape"
5
+
6
+ module Evilution::Reporter::HTML::DiffFormatter
7
+ module_function
8
+
9
+ def call(diff)
10
+ diff.split("\n").map { |line| format_line(line) }.join("\n")
11
+ end
12
+
13
+ def format_line(line)
14
+ css_class = line_class(line)
15
+ %(<span class="#{css_class}">#{Evilution::Reporter::HTML::Escape.call(line)}</span>)
16
+ end
17
+
18
+ def line_class(line)
19
+ if line.start_with?("- ")
20
+ "diff-removed"
21
+ elsif line.start_with?("+ ")
22
+ "diff-added"
23
+ else
24
+ ""
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require_relative "namespace"
5
+
6
+ module Evilution::Reporter::HTML::Escape
7
+ module_function
8
+
9
+ def call(text)
10
+ CGI.escapeHTML(text.to_s)
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../reporter"
4
+
5
+ # rubocop:disable Style/OneClassPerFile
6
+ class Evilution::Reporter::HTML # rubocop:disable Lint/EmptyClass
7
+ end
8
+
9
+ module Evilution::Reporter::HTML::Sections
10
+ end
11
+ # rubocop:enable Style/OneClassPerFile
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+ require_relative "stylesheet"
5
+ require_relative "baseline_keys"
6
+ require_relative "sections/header"
7
+ require_relative "sections/summary_cards"
8
+ require_relative "sections/baseline_comparison"
9
+ require_relative "sections/truncation_notice"
10
+ require_relative "sections/file_section"
11
+
12
+ class Evilution::Reporter::HTML::Report < Evilution::Reporter::HTML::Section
13
+ template "report"
14
+
15
+ def initialize(summary, baseline:, baseline_keys:, suggestion:)
16
+ @summary = summary
17
+ @baseline = baseline
18
+ @baseline_keys = baseline_keys
19
+ @suggestion = suggestion
20
+ end
21
+
22
+ private
23
+
24
+ def stylesheet
25
+ Evilution::Reporter::HTML::Stylesheet.call
26
+ end
27
+
28
+ def header
29
+ Evilution::Reporter::HTML::Sections::Header.new(@summary).render
30
+ end
31
+
32
+ def summary_cards
33
+ Evilution::Reporter::HTML::Sections::SummaryCards.new(@summary).render
34
+ end
35
+
36
+ def baseline_comparison
37
+ Evilution::Reporter::HTML::Sections::BaselineComparison.render_if(@baseline, @summary)
38
+ end
39
+
40
+ def truncation_notice
41
+ Evilution::Reporter::HTML::Sections::TruncationNotice.render_if(@summary)
42
+ end
43
+
44
+ def file_sections
45
+ files = group_by_file(@summary.results)
46
+ return '<p class="empty">No mutations generated.</p>' if files.empty?
47
+
48
+ files.map { |path, results| file_section(path, results) }.join("\n")
49
+ end
50
+
51
+ def file_section(path, results)
52
+ Evilution::Reporter::HTML::Sections::FileSection.new(
53
+ path, results,
54
+ suggestion: @suggestion,
55
+ baseline_keys: @baseline_keys
56
+ ).render
57
+ end
58
+
59
+ def group_by_file(results)
60
+ grouped = {}
61
+ results.each do |result|
62
+ path = result.mutation.file_path
63
+ grouped[path] ||= []
64
+ grouped[path] << result
65
+ end
66
+ grouped.sort_by { |path, _| path }.to_h
67
+ end
68
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "namespace"
5
+ require_relative "escape"
6
+
7
+ class Evilution::Reporter::HTML::Section
8
+ TEMPLATE_DIR = File.expand_path("templates", __dir__)
9
+
10
+ def self.template(name)
11
+ path = File.join(TEMPLATE_DIR, "#{name}.html.erb")
12
+ erb = ERB.new(File.read(path), trim_mode: "-")
13
+ erb.def_method(self, "render")
14
+ end
15
+
16
+ private
17
+
18
+ def h(text)
19
+ Evilution::Reporter::HTML::Escape.call(text)
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+
5
+ class Evilution::Reporter::HTML::Sections::BaselineComparison < Evilution::Reporter::HTML::Section
6
+ template "baseline_comparison"
7
+
8
+ def self.render_if(baseline, summary)
9
+ return "" unless baseline
10
+
11
+ new(baseline, summary).render
12
+ end
13
+
14
+ def initialize(baseline, summary)
15
+ @baseline = baseline
16
+ @summary = summary
17
+ end
18
+
19
+ private
20
+
21
+ def base_score
22
+ (@baseline["summary"] || {})["score"] || 0.0
23
+ end
24
+
25
+ def head_score
26
+ @summary.score
27
+ end
28
+
29
+ def delta
30
+ head_score - base_score
31
+ end
32
+
33
+ def delta_str
34
+ format("%+.2f%%", delta * 100)
35
+ end
36
+
37
+ def delta_class
38
+ if delta.positive?
39
+ "delta-positive"
40
+ elsif delta.negative?
41
+ "delta-negative"
42
+ else
43
+ "delta-neutral"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+ require_relative "error_entry"
5
+
6
+ class Evilution::Reporter::HTML::Sections::ErrorDetails < Evilution::Reporter::HTML::Section
7
+ template "error_details"
8
+
9
+ def self.render_if(errored)
10
+ return "" if errored.empty?
11
+
12
+ new(errored).render
13
+ end
14
+
15
+ def initialize(errored)
16
+ @errored = errored
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :errored
22
+
23
+ def sorted
24
+ errored.sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
25
+ end
26
+
27
+ def render_entry(result)
28
+ Evilution::Reporter::HTML::Sections::ErrorEntry.new(result).render
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+ require_relative "../diff_formatter"
5
+
6
+ class Evilution::Reporter::HTML::Sections::ErrorEntry < Evilution::Reporter::HTML::Section
7
+ template "error_entry"
8
+
9
+ def initialize(result)
10
+ @result = result
11
+ end
12
+
13
+ private
14
+
15
+ def message
16
+ @result.error_message.to_s
17
+ end
18
+
19
+ def diff_html
20
+ Evilution::Reporter::HTML::DiffFormatter.call(@result.mutation.diff)
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+ require_relative "mutation_map"
5
+ require_relative "survived_details"
6
+ require_relative "error_details"
7
+
8
+ class Evilution::Reporter::HTML::Sections::FileSection < Evilution::Reporter::HTML::Section
9
+ template "file_section"
10
+
11
+ def initialize(path, results, suggestion:, baseline_keys:)
12
+ @path = path
13
+ @results = results
14
+ @suggestion = suggestion
15
+ @baseline_keys = baseline_keys
16
+ end
17
+
18
+ private
19
+
20
+ def killed_count
21
+ @results.count(&:killed?)
22
+ end
23
+
24
+ def survived_count
25
+ @results.count(&:survived?)
26
+ end
27
+
28
+ def total
29
+ @results.length
30
+ end
31
+
32
+ def map_html
33
+ Evilution::Reporter::HTML::Sections::MutationMap.new(@results).render
34
+ end
35
+
36
+ def survived_html
37
+ Evilution::Reporter::HTML::Sections::SurvivedDetails.render_if(
38
+ @results.select(&:survived?),
39
+ suggestion: @suggestion,
40
+ baseline_keys: @baseline_keys
41
+ )
42
+ end
43
+
44
+ def error_html
45
+ Evilution::Reporter::HTML::Sections::ErrorDetails.render_if(@results.select(&:error?))
46
+ end
47
+ end