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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +5 -0
- data/CHANGELOG.md +16 -0
- data/README.md +1 -0
- data/lib/evilution/cli/parser/command_extractor.rb +77 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +103 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- data/lib/evilution/config.rb +14 -1
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/minitest.rb +16 -3
- data/lib/evilution/integration/rspec.rb +19 -7
- data/lib/evilution/isolation/fork.rb +1 -0
- data/lib/evilution/isolation/in_process.rb +1 -0
- data/lib/evilution/reporter/cli.rb +2 -1
- data/lib/evilution/reporter/html/assets/style.css +68 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
- data/lib/evilution/reporter/html/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
- data/lib/evilution/reporter/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +12 -8
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +9 -1
- data/lib/evilution/runner/baseline_runner.rb +71 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +255 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +57 -694
- data/lib/evilution/version.rb +1 -1
- 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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,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
|