evilution 0.24.0 → 0.26.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 +210 -0
- data/.claude/prompts/architect.md +14 -1
- data/.claude/skills/create-issue/SKILL.md +55 -0
- data/CHANGELOG.md +51 -0
- data/README.md +80 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/constant_names.rb +34 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +21 -1
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/invalid_input.rb +12 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +6 -0
- data/lib/evilution/config.rb +165 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +4 -155
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
- data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
- data/lib/evilution/integration/loading.rb +6 -0
- data/lib/evilution/integration/minitest.rb +10 -5
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +82 -7
- data/lib/evilution/isolation/fork.rb +25 -0
- data/lib/evilution/load_path/subpath_resolver.rb +25 -0
- data/lib/evilution/load_path.rb +4 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +37 -11
- data/lib/evilution/reporter/html/assets/style.css +17 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/json.rb +8 -2
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +13 -1
- data/lib/evilution/runner/baseline_runner.rb +23 -2
- data/lib/evilution/runner/isolation_resolver.rb +12 -1
- data/lib/evilution/runner/mutation_executor.rb +83 -13
- data/lib/evilution/runner/subject_pipeline.rb +18 -8
- data/lib/evilution/runner.rb +6 -0
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +46 -5
- data/lib/evilution/mcp/session_diff_tool.rb +0 -63
- data/lib/evilution/mcp/session_list_tool.rb +0 -50
- data/lib/evilution/mcp/session_show_tool.rb +0 -57
data/lib/evilution/mutation.rb
CHANGED
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "diff/lcs"
|
|
4
|
-
require "diff/lcs/hunk"
|
|
5
4
|
|
|
6
5
|
class Evilution::Mutation
|
|
7
6
|
attr_reader :subject, :operator_name, :original_source,
|
|
8
|
-
:mutated_source, :
|
|
7
|
+
:mutated_source, :original_slice, :mutated_slice,
|
|
8
|
+
:file_path, :line, :column, :parse_status
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# rubocop:disable Metrics/ParameterLists
|
|
11
|
+
def initialize(subject:, operator_name:, original_source:, mutated_source:,
|
|
12
|
+
file_path:, line:, column: 0, original_slice: nil, mutated_slice: nil,
|
|
13
|
+
parse_status: :ok)
|
|
14
|
+
# rubocop:enable Metrics/ParameterLists
|
|
11
15
|
@subject = subject
|
|
12
16
|
@operator_name = operator_name
|
|
13
17
|
@original_source = original_source
|
|
14
18
|
@mutated_source = mutated_source
|
|
19
|
+
@original_slice = original_slice
|
|
20
|
+
@mutated_slice = mutated_slice
|
|
15
21
|
@file_path = file_path
|
|
16
22
|
@line = line
|
|
17
23
|
@column = column
|
|
24
|
+
@parse_status = parse_status
|
|
18
25
|
@diff = nil
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
def unparseable?
|
|
29
|
+
@parse_status == :unparseable
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
def diff
|
|
22
33
|
@diff ||= compute_diff
|
|
23
34
|
end
|
|
24
35
|
|
|
36
|
+
def unified_diff
|
|
37
|
+
return @unified_diff if defined?(@unified_diff)
|
|
38
|
+
|
|
39
|
+
@unified_diff = compute_unified_diff
|
|
40
|
+
end
|
|
41
|
+
|
|
25
42
|
def strip_sources!
|
|
26
43
|
diff # ensure diff is cached before clearing sources
|
|
27
44
|
@original_source = nil
|
|
@@ -49,6 +66,29 @@ class Evilution::Mutation
|
|
|
49
66
|
result.join("\n")
|
|
50
67
|
end
|
|
51
68
|
|
|
69
|
+
def compute_unified_diff
|
|
70
|
+
return nil if @original_slice.nil? || @mutated_slice.nil?
|
|
71
|
+
|
|
72
|
+
original_lines = @original_slice.lines
|
|
73
|
+
mutated_lines = @mutated_slice.lines
|
|
74
|
+
body = ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
|
|
75
|
+
[
|
|
76
|
+
"--- a/#{file_path}",
|
|
77
|
+
"+++ b/#{file_path}",
|
|
78
|
+
"@@ -#{line},#{original_lines.length} +#{line},#{mutated_lines.length} @@",
|
|
79
|
+
body
|
|
80
|
+
].reject(&:empty?).join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_sdiff_change(change)
|
|
84
|
+
case change.action
|
|
85
|
+
when "=" then " #{change.old_element.chomp}"
|
|
86
|
+
when "-" then "-#{change.old_element.chomp}"
|
|
87
|
+
when "+" then "+#{change.new_element.chomp}"
|
|
88
|
+
when "!" then "-#{change.old_element.chomp}\n+#{change.new_element.chomp}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
52
92
|
public
|
|
53
93
|
|
|
54
94
|
def to_s
|
|
@@ -27,24 +27,62 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
27
27
|
def add_mutation(offset:, length:, replacement:, node:)
|
|
28
28
|
return if @filter && @filter.skip?(node)
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
surgery = Evilution::AST::SourceSurgeon.apply(
|
|
31
31
|
@file_source,
|
|
32
32
|
offset: offset,
|
|
33
33
|
length: length,
|
|
34
34
|
replacement: replacement
|
|
35
35
|
)
|
|
36
|
+
mutated_source = surgery.source
|
|
37
|
+
|
|
38
|
+
original_slice, mutated_slice = slice_affected_lines(
|
|
39
|
+
mutated_source: mutated_source,
|
|
40
|
+
offset: offset,
|
|
41
|
+
length: length,
|
|
42
|
+
replacement_bytesize: replacement.bytesize
|
|
43
|
+
)
|
|
36
44
|
|
|
37
45
|
@mutations << Evilution::Mutation.new(
|
|
38
46
|
subject: @subject,
|
|
39
47
|
operator_name: self.class.operator_name,
|
|
40
48
|
original_source: @file_source,
|
|
41
49
|
mutated_source: mutated_source,
|
|
50
|
+
original_slice: original_slice,
|
|
51
|
+
mutated_slice: mutated_slice,
|
|
52
|
+
parse_status: surgery.status,
|
|
42
53
|
file_path: @subject.file_path,
|
|
43
54
|
line: node.location.start_line,
|
|
44
55
|
column: node.location.start_column
|
|
45
56
|
)
|
|
46
57
|
end
|
|
47
58
|
|
|
59
|
+
NEWLINE_BYTE = 10
|
|
60
|
+
private_constant :NEWLINE_BYTE
|
|
61
|
+
|
|
62
|
+
def slice_affected_lines(mutated_source:, offset:, length:, replacement_bytesize:)
|
|
63
|
+
line_start = line_start_byte(@file_source, offset)
|
|
64
|
+
orig_line_end = line_end_byte(@file_source, [offset + length - 1, line_start].max)
|
|
65
|
+
mut_line_end = line_end_byte(mutated_source, [offset + replacement_bytesize - 1, line_start].max)
|
|
66
|
+
|
|
67
|
+
[
|
|
68
|
+
@file_source.byteslice(line_start, orig_line_end - line_start),
|
|
69
|
+
mutated_source.byteslice(line_start, mut_line_end - line_start)
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def line_start_byte(source, offset)
|
|
74
|
+
i = offset - 1
|
|
75
|
+
i -= 1 while i >= 0 && source.getbyte(i) != NEWLINE_BYTE
|
|
76
|
+
i + 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def line_end_byte(source, from)
|
|
80
|
+
limit = source.bytesize
|
|
81
|
+
i = from
|
|
82
|
+
i += 1 while i < limit && source.getbyte(i) != NEWLINE_BYTE
|
|
83
|
+
i < limit ? i + 1 : limit
|
|
84
|
+
end
|
|
85
|
+
|
|
48
86
|
def byteslice_source(offset, length)
|
|
49
87
|
@file_source.byteslice(offset, length).force_encoding(@file_source.encoding)
|
|
50
88
|
end
|
|
@@ -13,7 +13,7 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
|
|
|
13
13
|
def visit_call_node(node)
|
|
14
14
|
args = node.arguments&.arguments
|
|
15
15
|
|
|
16
|
-
if
|
|
16
|
+
if mutable?(node, args)
|
|
17
17
|
args.each_index do |i|
|
|
18
18
|
parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
|
|
19
19
|
replacement = parts.join(", ")
|
|
@@ -32,6 +32,10 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
|
|
|
32
32
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
|
+
def mutable?(node, args)
|
|
36
|
+
args && args.length >= 1 && positional_only?(args) && node.name != :[]=
|
|
37
|
+
end
|
|
38
|
+
|
|
35
39
|
def positional_only?(args)
|
|
36
40
|
args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
|
|
37
41
|
end
|
|
@@ -13,7 +13,7 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
|
|
|
13
13
|
def visit_call_node(node)
|
|
14
14
|
args = node.arguments&.arguments
|
|
15
15
|
|
|
16
|
-
if
|
|
16
|
+
if mutable?(node, args)
|
|
17
17
|
args.each_index do |i|
|
|
18
18
|
remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
|
|
19
19
|
replacement = remaining.join(", ")
|
|
@@ -32,6 +32,10 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
|
|
|
32
32
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
|
+
def mutable?(node, args)
|
|
36
|
+
args && args.length >= 2 && positional_only?(args) && node.name != :[]=
|
|
37
|
+
end
|
|
38
|
+
|
|
35
39
|
def positional_only?(args)
|
|
36
40
|
args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
|
|
37
41
|
end
|
|
@@ -21,31 +21,31 @@ class Evilution::Parallel::WorkQueue
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil)
|
|
25
|
-
|
|
26
|
-
raise ArgumentError, "prefetch must be a positive integer, got #{prefetch.inspect}" unless prefetch.is_a?(Integer) && prefetch >= 1
|
|
27
|
-
unless item_timeout.nil? || (item_timeout.is_a?(Numeric) && item_timeout.positive?)
|
|
28
|
-
raise ArgumentError, "item_timeout must be nil or a positive number, got #{item_timeout.inspect}"
|
|
29
|
-
end
|
|
30
|
-
|
|
24
|
+
def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil, worker_max_items: nil)
|
|
25
|
+
validate_init_args(size, prefetch, item_timeout, worker_max_items)
|
|
31
26
|
@size = size
|
|
32
27
|
@hooks = hooks
|
|
33
28
|
@prefetch = prefetch
|
|
34
29
|
@item_timeout = item_timeout
|
|
30
|
+
@worker_max_items = worker_max_items
|
|
35
31
|
@worker_stats = []
|
|
36
32
|
end
|
|
37
33
|
|
|
38
|
-
def map(items, &)
|
|
34
|
+
def map(items, &block)
|
|
39
35
|
return [] if items.empty?
|
|
40
36
|
|
|
37
|
+
@block = block
|
|
38
|
+
@retired_workers = []
|
|
41
39
|
worker_count = [@size, items.length].min
|
|
42
|
-
workers = spawn_workers(worker_count, &)
|
|
40
|
+
workers = spawn_workers(worker_count, &block)
|
|
43
41
|
|
|
44
42
|
begin
|
|
45
43
|
distribute_and_collect(items, workers)
|
|
46
44
|
ensure
|
|
47
45
|
shutdown_workers(workers)
|
|
48
|
-
@worker_stats = build_worker_stats(workers)
|
|
46
|
+
@worker_stats = @retired_workers + build_worker_stats(workers)
|
|
47
|
+
@block = nil
|
|
48
|
+
@retired_workers = nil
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -55,22 +55,64 @@ class Evilution::Parallel::WorkQueue
|
|
|
55
55
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
def validate_init_args(size, prefetch, item_timeout, worker_max_items)
|
|
59
|
+
validate_positive_int!(:size, size)
|
|
60
|
+
validate_positive_int!(:prefetch, prefetch)
|
|
61
|
+
validate_optional_positive_number!(:item_timeout, item_timeout)
|
|
62
|
+
validate_optional_positive_int!(:worker_max_items, worker_max_items)
|
|
63
|
+
end
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
res_read.close
|
|
66
|
-
worker_loop(cmd_read, res_write, &)
|
|
67
|
-
end
|
|
65
|
+
def validate_positive_int!(name, value)
|
|
66
|
+
return if value.is_a?(Integer) && value >= 1
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_optional_positive_int!(name, value)
|
|
72
|
+
return if value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "#{name} must be nil or a positive integer, got #{value.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_optional_positive_number!(name, value)
|
|
78
|
+
return if value.nil? || (value.is_a?(Numeric) && value.positive?)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "#{name} must be nil or a positive number, got #{value.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def spawn_workers(count, &block)
|
|
84
|
+
count.times.map { |slot| spawn_one_worker(worker_index: slot, &block) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# EV-kdns / GH #817: translate 0-based worker slot to parallel_tests'
|
|
88
|
+
# TEST_ENV_NUMBER convention ("" for slot 0, "2" for slot 1, ...). Rails
|
|
89
|
+
# apps interpolating TEST_ENV_NUMBER into database.yml get per-worker
|
|
90
|
+
# SQLite files, avoiding lock contention under jobs > 1.
|
|
91
|
+
def test_env_number_for(worker_index)
|
|
92
|
+
worker_index.zero? ? "" : (worker_index + 1).to_s
|
|
93
|
+
end
|
|
71
94
|
|
|
72
|
-
|
|
95
|
+
def spawn_one_worker(worker_index:, &block)
|
|
96
|
+
cmd_read, cmd_write = IO.pipe
|
|
97
|
+
res_read, res_write = IO.pipe
|
|
98
|
+
# Marshal payloads are ASCII-8BIT; pipes default to text mode and may
|
|
99
|
+
# transcode according to their external/internal encodings (influenced by
|
|
100
|
+
# Encoding.default_external and/or Encoding.default_internal — Rails sets
|
|
101
|
+
# the latter to UTF-8), failing on bytes with no mapping. Force binmode on
|
|
102
|
+
# all four ends.
|
|
103
|
+
[cmd_read, cmd_write, res_read, res_write].each(&:binmode)
|
|
104
|
+
|
|
105
|
+
pid = Process.fork do
|
|
106
|
+
cmd_write.close
|
|
107
|
+
res_read.close
|
|
108
|
+
ENV["TEST_ENV_NUMBER"] = test_env_number_for(worker_index)
|
|
109
|
+
worker_loop(cmd_read, res_write, &block)
|
|
73
110
|
end
|
|
111
|
+
|
|
112
|
+
cmd_read.close
|
|
113
|
+
res_write.close
|
|
114
|
+
|
|
115
|
+
{ pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0, pending: 0, worker_index: worker_index }
|
|
74
116
|
end
|
|
75
117
|
|
|
76
118
|
def worker_loop(cmd_read, res_write, &block)
|
|
@@ -135,33 +177,109 @@ class Evilution::Parallel::WorkQueue
|
|
|
135
177
|
end
|
|
136
178
|
|
|
137
179
|
readable.each do |io|
|
|
138
|
-
alive = handle_result(io, io_to_worker[io], items, state)
|
|
180
|
+
alive = handle_result(io, io_to_worker[io], items, state, workers, io_to_worker, result_ios)
|
|
139
181
|
result_ios.delete(io) unless alive
|
|
140
182
|
end
|
|
141
183
|
end
|
|
142
184
|
end
|
|
143
185
|
|
|
144
|
-
def handle_result(io, worker, items, state)
|
|
186
|
+
def handle_result(io, worker, items, state, workers, io_to_worker, result_ios)
|
|
145
187
|
message = read_result(io)
|
|
188
|
+
return handle_dead_worker(worker, state) if message.nil?
|
|
146
189
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
190
|
+
record_result(message, worker, state)
|
|
191
|
+
return false if recycle_and_dispatch(worker, items, state, workers, io_to_worker, result_ios)
|
|
192
|
+
return true if draining_for_recycle?(worker)
|
|
193
|
+
|
|
194
|
+
send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Once worker hits K, stop dispatching so pending drains to 0; recycle fires
|
|
199
|
+
# on the next result. Prevents prefetch > 1 from refilling pending forever.
|
|
200
|
+
def draining_for_recycle?(worker)
|
|
201
|
+
@worker_max_items && worker[:items_completed] >= @worker_max_items && worker[:pending].positive?
|
|
202
|
+
end
|
|
153
203
|
|
|
204
|
+
def handle_dead_worker(worker, state)
|
|
205
|
+
state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
|
|
206
|
+
state.in_flight -= worker[:pending]
|
|
207
|
+
worker[:pending] = 0
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def record_result(message, worker, state)
|
|
154
212
|
index, status, value = message
|
|
155
213
|
state.first_error = value if status == :error && state.first_error.nil?
|
|
156
214
|
state.results[index] = value if status == :ok
|
|
157
215
|
state.in_flight -= 1
|
|
158
216
|
worker[:pending] -= 1
|
|
159
217
|
worker[:items_completed] += 1
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def recycle_and_dispatch(worker, items, state, workers, io_to_worker, result_ios)
|
|
221
|
+
return false unless should_recycle?(worker, state, items)
|
|
222
|
+
|
|
223
|
+
new_worker = recycle_worker(worker, workers, io_to_worker, result_ios)
|
|
224
|
+
send_item(new_worker, items, state) if state.next_index < items.length && state.first_error.nil?
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def should_recycle?(worker, state, items)
|
|
229
|
+
return false unless @worker_max_items
|
|
230
|
+
return false if worker[:items_completed] < @worker_max_items
|
|
231
|
+
return false unless worker[:pending].zero?
|
|
232
|
+
return false unless state.next_index < items.length
|
|
233
|
+
return false unless state.first_error.nil?
|
|
160
234
|
|
|
161
|
-
send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
|
|
162
235
|
true
|
|
163
236
|
end
|
|
164
237
|
|
|
238
|
+
def recycle_worker(old_worker, workers, io_to_worker, result_ios)
|
|
239
|
+
io_to_worker.delete(old_worker[:res_read])
|
|
240
|
+
result_ios.delete(old_worker[:res_read])
|
|
241
|
+
retire_worker(old_worker)
|
|
242
|
+
|
|
243
|
+
new_worker = spawn_one_worker(worker_index: old_worker[:worker_index], &@block)
|
|
244
|
+
workers[workers.index(old_worker)] = new_worker
|
|
245
|
+
io_to_worker[new_worker[:res_read]] = new_worker
|
|
246
|
+
result_ios << new_worker[:res_read]
|
|
247
|
+
|
|
248
|
+
new_worker
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def retire_worker(worker)
|
|
252
|
+
begin
|
|
253
|
+
write_message(worker[:cmd_write], SHUTDOWN)
|
|
254
|
+
rescue Errno::EPIPE
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
busy, wall = drain_worker_stats(worker)
|
|
259
|
+
|
|
260
|
+
worker[:cmd_write].close unless worker[:cmd_write].closed?
|
|
261
|
+
worker[:res_read].close unless worker[:res_read].closed?
|
|
262
|
+
begin
|
|
263
|
+
Process.wait(worker[:pid])
|
|
264
|
+
rescue Errno::ECHILD
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
@retired_workers << WorkerStat.new(worker[:pid], worker[:items_completed], busy, wall)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def drain_worker_stats(worker)
|
|
272
|
+
return [0.0, 0.0] unless worker[:res_read].wait_readable(TIMING_GRACE_PERIOD)
|
|
273
|
+
|
|
274
|
+
message = read_result(worker[:res_read])
|
|
275
|
+
return [0.0, 0.0] if message.nil?
|
|
276
|
+
|
|
277
|
+
tag, busy_time, wall_time = message
|
|
278
|
+
return [0.0, 0.0] unless tag == STATS
|
|
279
|
+
|
|
280
|
+
[busy_time, wall_time]
|
|
281
|
+
end
|
|
282
|
+
|
|
165
283
|
def send_item(worker, items, state)
|
|
166
284
|
write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
|
|
167
285
|
state.next_index += 1
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../evilution"
|
|
6
|
+
|
|
7
|
+
# EV-kdns / GH #817: nudge users running parallel jobs against SQLite to adopt
|
|
8
|
+
# the parallel_tests convention. WorkQueue sets TEST_ENV_NUMBER per worker, but
|
|
9
|
+
# that only helps if config/database.yml interpolates it into the database path.
|
|
10
|
+
# Without per-worker DB files, concurrent workers pile up on one SQLite file and
|
|
11
|
+
# surface ActiveRecord::StatementTimeout / SQLite3::BusyException — noise that
|
|
12
|
+
# MutationExecutor demotes to :neutral but still wastes wall-clock time.
|
|
13
|
+
module Evilution::ParallelDbWarning
|
|
14
|
+
DATABASE_YML = File.join("config", "database.yml")
|
|
15
|
+
MESSAGE = "[evilution] Parallel run (jobs > 1) detected with SQLite in " \
|
|
16
|
+
"config/database.yml. Interpolate ENV['TEST_ENV_NUMBER'] into the " \
|
|
17
|
+
"test database path for per-worker DB isolation. See README."
|
|
18
|
+
|
|
19
|
+
@warned_roots = {}
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def warn_if_sqlite_parallel(config, output: $stderr, root: Dir.pwd)
|
|
24
|
+
return unless config.jobs > 1
|
|
25
|
+
return unless sqlite_in_test_config?(root)
|
|
26
|
+
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
return if @warned_roots[root]
|
|
29
|
+
|
|
30
|
+
@warned_roots[root] = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
output.puts(MESSAGE)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
@mutex.synchronize { @warned_roots.clear }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Only the `test` section matters for parallel mutation runs. Scanning the
|
|
43
|
+
# whole file would false-positive when dev/prod use SQLite but test uses
|
|
44
|
+
# Postgres/MySQL. Parse failures (ERB errors, custom helpers, anchor
|
|
45
|
+
# gymnastics) fall back to "no warning" rather than guessing.
|
|
46
|
+
def sqlite_in_test_config?(root)
|
|
47
|
+
path = File.join(root, DATABASE_YML)
|
|
48
|
+
return false unless File.exist?(path)
|
|
49
|
+
|
|
50
|
+
parsed = parse_database_yml(path)
|
|
51
|
+
return false unless parsed.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
test_config = parsed["test"]
|
|
54
|
+
return false unless test_config.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
adapter = test_config["adapter"]
|
|
57
|
+
adapter.is_a?(String) && adapter.downcase.include?("sqlite")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_database_yml(path)
|
|
61
|
+
content = File.read(path)
|
|
62
|
+
rendered = ERB.new(content).result
|
|
63
|
+
YAML.safe_load(rendered, aliases: true)
|
|
64
|
+
rescue StandardError, SyntaxError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -7,6 +7,17 @@ class Evilution::Reporter::CLI
|
|
|
7
7
|
|
|
8
8
|
def call(summary)
|
|
9
9
|
lines = []
|
|
10
|
+
append_metrics(lines, summary)
|
|
11
|
+
append_sections(lines, summary)
|
|
12
|
+
lines << ""
|
|
13
|
+
lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
|
|
14
|
+
lines << result_line(summary)
|
|
15
|
+
lines.join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def append_metrics(lines, summary)
|
|
10
21
|
lines << header
|
|
11
22
|
lines << SEPARATOR
|
|
12
23
|
lines << ""
|
|
@@ -16,20 +27,18 @@ class Evilution::Reporter::CLI
|
|
|
16
27
|
lines << efficiency_line(summary) if summary.duration.positive?
|
|
17
28
|
peak = summary.peak_memory_mb
|
|
18
29
|
lines << peak_memory_line(peak) if peak
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append_sections(lines, summary)
|
|
19
33
|
append_survived(lines, summary)
|
|
20
34
|
append_neutral(lines, summary)
|
|
21
35
|
append_equivalent(lines, summary)
|
|
36
|
+
append_unresolved(lines, summary)
|
|
37
|
+
append_unparseable(lines, summary)
|
|
22
38
|
append_errors(lines, summary)
|
|
23
39
|
append_disabled(lines, summary)
|
|
24
|
-
lines << ""
|
|
25
|
-
lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
|
|
26
|
-
lines << result_line(summary)
|
|
27
|
-
|
|
28
|
-
lines.join("\n")
|
|
29
40
|
end
|
|
30
41
|
|
|
31
|
-
private
|
|
32
|
-
|
|
33
42
|
def append_survived(lines, summary)
|
|
34
43
|
gaps = summary.coverage_gaps
|
|
35
44
|
return unless gaps.any?
|
|
@@ -55,6 +64,22 @@ class Evilution::Reporter::CLI
|
|
|
55
64
|
summary.equivalent_results.each { |result| lines << format_neutral(result) }
|
|
56
65
|
end
|
|
57
66
|
|
|
67
|
+
def append_unresolved(lines, summary)
|
|
68
|
+
return unless summary.unresolved_results.any?
|
|
69
|
+
|
|
70
|
+
lines << ""
|
|
71
|
+
lines << "Unresolved mutations (no test file resolved):"
|
|
72
|
+
summary.unresolved_results.each { |result| lines << format_neutral(result) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def append_unparseable(lines, summary)
|
|
76
|
+
return unless summary.unparseable_results.any?
|
|
77
|
+
|
|
78
|
+
lines << ""
|
|
79
|
+
lines << "Unparseable mutations (mutated source did not parse):"
|
|
80
|
+
summary.unparseable_results.each { |result| lines << format_neutral(result) }
|
|
81
|
+
end
|
|
82
|
+
|
|
58
83
|
def append_errors(lines, summary)
|
|
59
84
|
errored = summary.results.select(&:error?)
|
|
60
85
|
return if errored.empty?
|
|
@@ -91,14 +116,14 @@ class Evilution::Reporter::CLI
|
|
|
91
116
|
parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
|
|
92
117
|
parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
|
|
93
118
|
parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
|
|
119
|
+
parts += ", #{summary.unparseable} unparseable" if summary.unparseable.positive?
|
|
94
120
|
parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
|
|
95
121
|
parts
|
|
96
122
|
end
|
|
97
123
|
|
|
98
124
|
def score_line(summary)
|
|
99
|
-
denominator = summary.total - summary.errors - summary.neutral - summary.equivalent - summary.unresolved
|
|
100
125
|
score_pct = format_pct(summary.score)
|
|
101
|
-
"Score: #{score_pct} (#{summary.killed}/#{
|
|
126
|
+
"Score: #{score_pct} (#{summary.killed}/#{summary.score_denominator})"
|
|
102
127
|
end
|
|
103
128
|
|
|
104
129
|
def duration_line(summary)
|
|
@@ -119,8 +144,9 @@ class Evilution::Reporter::CLI
|
|
|
119
144
|
operators = gap.operator_names.join(", ")
|
|
120
145
|
" #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{operators}]"
|
|
121
146
|
end
|
|
122
|
-
|
|
123
|
-
"
|
|
147
|
+
body = gap.mutation_results.first.mutation.unified_diff || gap.primary_diff
|
|
148
|
+
indented = body.split("\n").map { |l| " #{l}" }.join("\n")
|
|
149
|
+
"#{header}\n#{indented}"
|
|
124
150
|
end
|
|
125
151
|
|
|
126
152
|
def format_neutral(result)
|
|
@@ -26,6 +26,8 @@ h1 { font-size: 1.5rem; color: #f0f6fc; }
|
|
|
26
26
|
.map-line.error { color: #f85149; }
|
|
27
27
|
.map-line.neutral { color: #8b949e; }
|
|
28
28
|
.map-line.equivalent { color: #8b949e; }
|
|
29
|
+
.map-line.unresolved { color: #8b949e; }
|
|
30
|
+
.map-line.unparseable { color: #8b949e; }
|
|
29
31
|
.line-number { min-width: 60px; color: #8b949e; }
|
|
30
32
|
.operator { flex: 1; }
|
|
31
33
|
.status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
|
|
@@ -34,6 +36,9 @@ h1 { font-size: 1.5rem; color: #f0f6fc; }
|
|
|
34
36
|
.status-badge.timeout { background: #4a3a10; }
|
|
35
37
|
.status-badge.neutral { background: #21262d; }
|
|
36
38
|
.status-badge.equivalent { background: #21262d; }
|
|
39
|
+
.status-badge.error { background: #4a3a10; }
|
|
40
|
+
.status-badge.unresolved { background: #21262d; }
|
|
41
|
+
.status-badge.unparseable { background: #21262d; }
|
|
37
42
|
.survived-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
38
43
|
.survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
39
44
|
.survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
|
@@ -63,6 +68,18 @@ h1 { font-size: 1.5rem; color: #f0f6fc; }
|
|
|
63
68
|
.error-header .operator { color: #d29922; font-weight: bold; }
|
|
64
69
|
.error-header .location { color: #8b949e; font-family: monospace; }
|
|
65
70
|
.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; }
|
|
71
|
+
.unresolved-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
72
|
+
.unresolved-details h3 { color: #8b949e; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
73
|
+
.unresolved-details ul { list-style: none; }
|
|
74
|
+
.unresolved-details li { font-size: 0.85rem; padding: 0.2rem 0; color: #8b949e; }
|
|
75
|
+
.unresolved-details .operator { color: #c9d1d9; font-family: monospace; margin-right: 0.75rem; }
|
|
76
|
+
.unresolved-details .location { font-family: monospace; }
|
|
77
|
+
.unparseable-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
78
|
+
.unparseable-details h3 { color: #8b949e; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
79
|
+
.unparseable-details ul { list-style: none; }
|
|
80
|
+
.unparseable-details li { font-size: 0.85rem; padding: 0.2rem 0; color: #8b949e; }
|
|
81
|
+
.unparseable-details .operator { color: #c9d1d9; font-family: monospace; margin-right: 0.75rem; }
|
|
82
|
+
.unparseable-details .location { font-family: monospace; }
|
|
66
83
|
.survived-entry.regression { border-color: #f85149; background: #2a1010; }
|
|
67
84
|
.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
85
|
footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
|
|
@@ -4,6 +4,9 @@ require_relative "../sections"
|
|
|
4
4
|
require_relative "mutation_map"
|
|
5
5
|
require_relative "survived_details"
|
|
6
6
|
require_relative "error_details"
|
|
7
|
+
require_relative "neutral_details"
|
|
8
|
+
require_relative "unresolved_details"
|
|
9
|
+
require_relative "unparseable_details"
|
|
7
10
|
|
|
8
11
|
class Evilution::Reporter::HTML::Sections::FileSection < Evilution::Reporter::HTML::Section
|
|
9
12
|
template "file_section"
|
|
@@ -44,4 +47,16 @@ class Evilution::Reporter::HTML::Sections::FileSection < Evilution::Reporter::HT
|
|
|
44
47
|
def error_html
|
|
45
48
|
Evilution::Reporter::HTML::Sections::ErrorDetails.render_if(@results.select(&:error?))
|
|
46
49
|
end
|
|
50
|
+
|
|
51
|
+
def neutral_html
|
|
52
|
+
Evilution::Reporter::HTML::Sections::NeutralDetails.render_if(@results.select(&:neutral?))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unresolved_html
|
|
56
|
+
Evilution::Reporter::HTML::Sections::UnresolvedDetails.render_if(@results.select(&:unresolved?))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unparseable_html
|
|
60
|
+
Evilution::Reporter::HTML::Sections::UnparseableDetails.render_if(@results.select(&:unparseable?))
|
|
61
|
+
end
|
|
47
62
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sections"
|
|
4
|
+
|
|
5
|
+
class Evilution::Reporter::HTML::Sections::NeutralDetails < Evilution::Reporter::HTML::Section
|
|
6
|
+
template "neutral_details"
|
|
7
|
+
|
|
8
|
+
def self.render_if(neutral)
|
|
9
|
+
return "" if neutral.empty?
|
|
10
|
+
|
|
11
|
+
new(neutral).render
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(neutral)
|
|
15
|
+
@neutral = neutral
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :neutral
|
|
21
|
+
|
|
22
|
+
def sorted
|
|
23
|
+
neutral.sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
|
|
24
|
+
end
|
|
25
|
+
end
|