evilution 0.28.0 → 0.30.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 +106 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +49 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/constant_names.rb +28 -11
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/ast/pattern/parser.rb +29 -17
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/commands/session_diff.rb +6 -4
- data/lib/evilution/cli/commands/subjects.rb +6 -3
- data/lib/evilution/cli/commands/util_mutation.rb +24 -19
- data/lib/evilution/cli/parser/command_extractor.rb +12 -12
- data/lib/evilution/cli/parser/file_args.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +31 -3
- data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
- data/lib/evilution/cli/parser.rb +18 -20
- data/lib/evilution/cli/printers/environment.rb +19 -19
- data/lib/evilution/cli/printers/session_diff.rb +8 -8
- data/lib/evilution/compare/normalizer.rb +10 -5
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +21 -11
- data/lib/evilution/disable_comment.rb +21 -12
- data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
- data/lib/evilution/feedback/setup_warning.rb +79 -0
- data/lib/evilution/gem_detector.rb +132 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +60 -16
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +20 -1
- data/lib/evilution/isolation/fork.rb +104 -27
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
- data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
- data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +10 -2
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
- data/lib/evilution/mcp/mutate_tool.rb +49 -17
- data/lib/evilution/mcp/session_tool.rb +34 -22
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +26 -16
- data/lib/evilution/mutator/base.rb +66 -16
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
- data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
- data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
- data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
- data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
- data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
- data/lib/evilution/mutator/operator/case_when.rb +7 -5
- data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
- data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
- data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
- data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
- data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
- data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
- data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
- data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
- data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
- data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
- data/lib/evilution/parallel/work_queue/worker.rb +10 -7
- data/lib/evilution/parallel/work_queue.rb +35 -18
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +54 -18
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
- data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +20 -9
- data/lib/evilution/runner/diagnostics.rb +13 -9
- data/lib/evilution/runner/isolation_resolver.rb +75 -12
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
- data/lib/evilution/runner/mutation_executor.rb +2 -0
- data/lib/evilution/runner/mutation_planner.rb +53 -16
- data/lib/evilution/runner/subject_pipeline.rb +21 -11
- data/lib/evilution/runner.rb +3 -3
- data/lib/evilution/session/diff.rb +15 -6
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/spec_ast_cache.rb +26 -12
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +2 -0
- data/schema/evilution.config.schema.json +205 -0
- data/script/build_runtime_snapshot +88 -0
- data/script/memory_check +11 -5
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- data/scripts/benchmark_density +10 -9
- data/scripts/compare_mutations +38 -21
- data/scripts/mutant_json_adapter +7 -4
- metadata +16 -2
|
@@ -18,8 +18,17 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
|
|
|
18
18
|
@notifier.start(mutations.length)
|
|
19
19
|
pool = @pool_factory.call
|
|
20
20
|
state = { results: [], truncated: false, completed: 0 }
|
|
21
|
-
all_worker_stats =
|
|
21
|
+
all_worker_stats = run_batches(mutations, pool, baseline_result, integration, state)
|
|
22
22
|
|
|
23
|
+
log_worker_diagnostics(all_worker_stats)
|
|
24
|
+
@notifier.finish
|
|
25
|
+
build_result(state)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def run_batches(mutations, pool, baseline_result, integration, state)
|
|
31
|
+
all_worker_stats = []
|
|
23
32
|
mutations.each_slice(@config.jobs) do |batch|
|
|
24
33
|
break if state[:truncated]
|
|
25
34
|
|
|
@@ -27,24 +36,37 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
|
|
|
27
36
|
all_worker_stats.concat(pool.worker_stats)
|
|
28
37
|
process_batch(batch_results, baseline_result, state)
|
|
29
38
|
end
|
|
39
|
+
all_worker_stats
|
|
40
|
+
end
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
@
|
|
33
|
-
|
|
42
|
+
def log_worker_diagnostics(all_worker_stats)
|
|
43
|
+
return unless @diagnostics
|
|
44
|
+
|
|
45
|
+
@diagnostics.log_worker_stats(@diagnostics.aggregate_worker_stats(all_worker_stats))
|
|
34
46
|
end
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
def build_result(state)
|
|
49
|
+
Evilution::Runner::MutationExecutor::ExecutionResult.new(results: state[:results], truncated: state[:truncated])
|
|
50
|
+
end
|
|
37
51
|
|
|
38
52
|
def run_batch(batch, pool, integration)
|
|
39
|
-
|
|
40
|
-
worker_results = run_uncached(batch, uncached_indices, pool, integration)
|
|
41
|
-
compact_results = merge(batch, uncached_indices, cached_results, worker_results)
|
|
42
|
-
batch_results = batch
|
|
43
|
-
|
|
53
|
+
partition = @cache.partition(batch, packer: @packer)
|
|
54
|
+
worker_results = run_uncached(batch, partition.uncached_indices, pool, integration)
|
|
55
|
+
compact_results = merge(batch, partition.uncached_indices, partition.cached_results, worker_results)
|
|
56
|
+
batch_results = rebuild_results(batch, compact_results)
|
|
57
|
+
cache_results(batch_results, partition.uncached_indices)
|
|
44
58
|
batch.each(&:strip_sources!)
|
|
45
59
|
batch_results
|
|
46
60
|
end
|
|
47
61
|
|
|
62
|
+
def rebuild_results(batch, compact_results)
|
|
63
|
+
batch.zip(compact_results).map { |m, h| @packer.rebuild(m, h) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cache_results(batch_results, uncached_indices)
|
|
67
|
+
uncached_indices.each { |i| @cache.store(batch_results[i].mutation, batch_results[i]) }
|
|
68
|
+
end
|
|
69
|
+
|
|
48
70
|
def run_uncached(batch, uncached_indices, pool, integration)
|
|
49
71
|
return [] if uncached_indices.empty?
|
|
50
72
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "../runner"
|
|
4
4
|
|
|
5
5
|
class Evilution::Runner::MutationExecutor
|
|
6
|
+
ExecutionResult = Data.define(:results, :truncated)
|
|
7
|
+
|
|
6
8
|
autoload :ResultCache, File.expand_path("mutation_executor/result_cache", __dir__)
|
|
7
9
|
autoload :ResultPacker, File.expand_path("mutation_executor/result_packer", __dir__)
|
|
8
10
|
autoload :ResultNotifier, File.expand_path("mutation_executor/result_notifier", __dir__)
|
|
@@ -9,6 +9,12 @@ require_relative "../equivalent/detector"
|
|
|
9
9
|
class Evilution::Runner::MutationPlanner
|
|
10
10
|
Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, keyword_init: true)
|
|
11
11
|
|
|
12
|
+
GenerationResult = Data.define(:mutations, :skipped)
|
|
13
|
+
DisabledFilterResult = Data.define(:enabled, :disabled)
|
|
14
|
+
SigFilterResult = Data.define(:enabled, :skipped)
|
|
15
|
+
EquivalentFilterResult = Data.define(:equivalent, :enabled)
|
|
16
|
+
private_constant :GenerationResult, :DisabledFilterResult, :SigFilterResult, :EquivalentFilterResult
|
|
17
|
+
|
|
12
18
|
def initialize(config, registry:, disable_detector: Evilution::DisableComment.new,
|
|
13
19
|
sig_detector: Evilution::AST::SorbetSigDetector.new)
|
|
14
20
|
@config = config
|
|
@@ -20,25 +26,55 @@ class Evilution::Runner::MutationPlanner
|
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def call(subjects)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
disabled_mutations =
|
|
29
|
+
generation = generate(subjects)
|
|
30
|
+
deduped = deduplicate(generation.mutations)
|
|
31
|
+
disabled_filter = filter_disabled(deduped)
|
|
32
|
+
disabled_mutations = compute_disabled_mutations(disabled_filter)
|
|
33
|
+
sig_filter = filter_sig_blocks(disabled_filter.enabled)
|
|
34
|
+
equivalent_filter = filter_equivalent(sig_filter.enabled)
|
|
35
|
+
|
|
36
|
+
build_plan(equivalent_filter, disabled_mutations, total_skipped(generation, disabled_filter, sig_filter))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :config, :registry
|
|
42
|
+
|
|
43
|
+
def compute_disabled_mutations(disabled_filter)
|
|
44
|
+
return [] unless config.show_disabled?
|
|
45
|
+
|
|
46
|
+
disabled_filter.disabled.each(&:strip_sources!)
|
|
47
|
+
disabled_filter.disabled
|
|
48
|
+
end
|
|
27
49
|
|
|
28
|
-
|
|
29
|
-
|
|
50
|
+
def total_skipped(generation, disabled_filter, sig_filter)
|
|
51
|
+
generation.skipped + disabled_filter.disabled.length + sig_filter.skipped
|
|
52
|
+
end
|
|
30
53
|
|
|
54
|
+
def build_plan(equivalent_filter, disabled_mutations, skipped_count)
|
|
31
55
|
Plan.new(
|
|
32
|
-
enabled: enabled,
|
|
33
|
-
equivalent: equivalent,
|
|
34
|
-
skipped_count:
|
|
56
|
+
enabled: equivalent_filter.enabled,
|
|
57
|
+
equivalent: equivalent_filter.equivalent,
|
|
58
|
+
skipped_count: skipped_count,
|
|
35
59
|
disabled_mutations: disabled_mutations
|
|
36
60
|
)
|
|
37
61
|
end
|
|
38
62
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
# Two operators can independently emit the same byte-level mutation
|
|
64
|
+
# (statement_deletion deleting a trailing literal AND last_expression_removal
|
|
65
|
+
# producing the same result, for example). Running both is wasted compute
|
|
66
|
+
# and inflates the denominator. Key is (file_path, mutated_source) — two
|
|
67
|
+
# mutations producing the same resulting source are operationally identical
|
|
68
|
+
# regardless of operator name. Order-stable: first occurrence wins, so
|
|
69
|
+
# operator-name ordering in the registry determines which name surfaces.
|
|
70
|
+
def deduplicate(mutations)
|
|
71
|
+
seen = {}
|
|
72
|
+
mutations.each do |mutation|
|
|
73
|
+
key = [mutation.file_path, mutation.mutated_source]
|
|
74
|
+
seen[key] ||= mutation
|
|
75
|
+
end
|
|
76
|
+
seen.values
|
|
77
|
+
end
|
|
42
78
|
|
|
43
79
|
def generate(subjects)
|
|
44
80
|
filter = build_ignore_filter
|
|
@@ -47,7 +83,7 @@ class Evilution::Runner::MutationPlanner
|
|
|
47
83
|
registry.mutations_for(subject, filter: filter, operator_options: operator_options)
|
|
48
84
|
end
|
|
49
85
|
skipped = filter ? filter.skipped_count : 0
|
|
50
|
-
|
|
86
|
+
GenerationResult.new(mutations: mutations, skipped: skipped)
|
|
51
87
|
end
|
|
52
88
|
|
|
53
89
|
def build_operator_options
|
|
@@ -73,7 +109,7 @@ class Evilution::Runner::MutationPlanner
|
|
|
73
109
|
end
|
|
74
110
|
end
|
|
75
111
|
|
|
76
|
-
|
|
112
|
+
DisabledFilterResult.new(enabled: enabled, disabled: disabled)
|
|
77
113
|
end
|
|
78
114
|
|
|
79
115
|
def mutation_disabled?(mutation)
|
|
@@ -102,7 +138,7 @@ class Evilution::Runner::MutationPlanner
|
|
|
102
138
|
end
|
|
103
139
|
end
|
|
104
140
|
|
|
105
|
-
|
|
141
|
+
SigFilterResult.new(enabled: enabled, skipped: skipped)
|
|
106
142
|
end
|
|
107
143
|
|
|
108
144
|
def mutation_in_sig_block?(mutation)
|
|
@@ -120,6 +156,7 @@ class Evilution::Runner::MutationPlanner
|
|
|
120
156
|
end
|
|
121
157
|
|
|
122
158
|
def filter_equivalent(mutations)
|
|
123
|
-
Evilution::Equivalent::Detector.new.call(mutations)
|
|
159
|
+
equivalent, enabled = Evilution::Equivalent::Detector.new.call(mutations)
|
|
160
|
+
EquivalentFilterResult.new(equivalent: equivalent, enabled: enabled)
|
|
124
161
|
end
|
|
125
162
|
end
|
|
@@ -104,17 +104,27 @@ class Evilution::Runner::SubjectPipeline
|
|
|
104
104
|
|
|
105
105
|
def target_matcher
|
|
106
106
|
target = config.target
|
|
107
|
-
if target.end_with?("*")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
107
|
+
return wildcard_matcher(target.chomp("*")) if target.end_with?("*")
|
|
108
|
+
return prefix_matcher(target) if target.end_with?("#", ".")
|
|
109
|
+
return exact_matcher(target) if target.include?("#") || target.include?(".")
|
|
110
|
+
|
|
111
|
+
class_matcher(target)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def wildcard_matcher(prefix)
|
|
115
|
+
->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prefix_matcher(prefix)
|
|
119
|
+
->(s) { s.name.start_with?(prefix) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def exact_matcher(target)
|
|
123
|
+
->(s) { s.name == target }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def class_matcher(target)
|
|
127
|
+
->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
|
|
118
128
|
end
|
|
119
129
|
|
|
120
130
|
def filter_by_line_ranges(subjects)
|
data/lib/evilution/runner.rb
CHANGED
|
@@ -32,13 +32,13 @@ class Evilution::Runner
|
|
|
32
32
|
plan = mutation_planner.call(subjects)
|
|
33
33
|
release_subject_nodes(subjects)
|
|
34
34
|
clear_operator_caches
|
|
35
|
-
|
|
36
|
-
results
|
|
35
|
+
execution = run_mutations(plan.enabled, baseline_result)
|
|
36
|
+
results = execution.results + equivalent_results(plan.equivalent)
|
|
37
37
|
log_memory("after run_mutations", "#{results.length} results")
|
|
38
38
|
|
|
39
39
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
40
40
|
|
|
41
|
-
summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: truncated,
|
|
41
|
+
summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: execution.truncated,
|
|
42
42
|
skipped: plan.skipped_count,
|
|
43
43
|
disabled_mutations: plan.disabled_mutations)
|
|
44
44
|
output_report(summary)
|
|
@@ -38,20 +38,29 @@ class Evilution::Session::Diff
|
|
|
38
38
|
def call(base_data, head_data)
|
|
39
39
|
base_survivors = base_data["survived"] || []
|
|
40
40
|
head_survivors = head_data["survived"] || []
|
|
41
|
-
|
|
42
|
-
base_keys = base_survivors.to_set { |m| mutation_key(m) }
|
|
43
|
-
head_keys = head_survivors.to_set { |m| mutation_key(m) }
|
|
41
|
+
fixed, new_survivors, persistent = partition_survivors(base_survivors, head_survivors)
|
|
44
42
|
|
|
45
43
|
Result.new(
|
|
46
44
|
summary: build_summary_diff(base_data, head_data),
|
|
47
|
-
fixed:
|
|
48
|
-
new_survivors:
|
|
49
|
-
persistent:
|
|
45
|
+
fixed: fixed,
|
|
46
|
+
new_survivors: new_survivors,
|
|
47
|
+
persistent: persistent
|
|
50
48
|
)
|
|
51
49
|
end
|
|
52
50
|
|
|
53
51
|
private
|
|
54
52
|
|
|
53
|
+
def partition_survivors(base_survivors, head_survivors)
|
|
54
|
+
base_keys = base_survivors.to_set { |m| mutation_key(m) }
|
|
55
|
+
head_keys = head_survivors.to_set { |m| mutation_key(m) }
|
|
56
|
+
|
|
57
|
+
[
|
|
58
|
+
base_survivors.reject { |m| head_keys.include?(mutation_key(m)) },
|
|
59
|
+
head_survivors.reject { |m| base_keys.include?(mutation_key(m)) },
|
|
60
|
+
head_survivors.select { |m| base_keys.include?(mutation_key(m)) }
|
|
61
|
+
]
|
|
62
|
+
end
|
|
63
|
+
|
|
55
64
|
def build_summary_diff(base_data, head_data)
|
|
56
65
|
base = extract_summary_values(base_data)
|
|
57
66
|
head = extract_summary_values(head_data)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
|
|
5
|
+
module Evilution::Session::Schema
|
|
6
|
+
CURRENT_VERSION = 1
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Validates the schema_version of a parsed session JSON Hash.
|
|
11
|
+
#
|
|
12
|
+
# Sessions written before schema_version was introduced (key absent
|
|
13
|
+
# entirely) are treated as CURRENT_VERSION — the JSON shape that defined
|
|
14
|
+
# version 1. A key that is explicitly present but null/non-positive/non-
|
|
15
|
+
# integer is rejected as invalid; "missing" and "corrupted" must not
|
|
16
|
+
# collapse into the same lenient bucket. A schema_version newer than this
|
|
17
|
+
# gem supports raises Evilution::Error with an explicit "upgrade the gem"
|
|
18
|
+
# message so future writers cannot be silently misread.
|
|
19
|
+
def validate!(data, source: nil)
|
|
20
|
+
return unless data.key?("schema_version") || data.key?(:schema_version)
|
|
21
|
+
|
|
22
|
+
raw = data.fetch("schema_version") { data[:schema_version] }
|
|
23
|
+
raise_invalid!(raw, source) unless raw.is_a?(Integer) && raw.positive?
|
|
24
|
+
return if raw <= CURRENT_VERSION
|
|
25
|
+
|
|
26
|
+
raise_future!(raw, source)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raise_invalid!(value, source)
|
|
30
|
+
raise Evilution::Error,
|
|
31
|
+
"invalid schema_version #{value.inspect}#{location_clause(source)}: must be a positive Integer"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def raise_future!(value, source)
|
|
35
|
+
raise Evilution::Error,
|
|
36
|
+
"session file#{location_clause(source)} has schema_version #{value}, " \
|
|
37
|
+
"newer than this evilution gem supports (current: #{CURRENT_VERSION}). " \
|
|
38
|
+
"Upgrade the evilution gem."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def location_clause(source)
|
|
42
|
+
source ? " at #{source}" : ""
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
|
|
8
8
|
require_relative "../session"
|
|
9
|
+
require_relative "schema"
|
|
9
10
|
|
|
10
11
|
class Evilution::Session::Store
|
|
11
12
|
DEFAULT_DIR = ".evilution/results"
|
|
@@ -38,7 +39,9 @@ class Evilution::Session::Store
|
|
|
38
39
|
def load(path)
|
|
39
40
|
raise Evilution::Error, "session file not found: #{path}" unless File.exist?(path)
|
|
40
41
|
|
|
41
|
-
JSON.parse(File.read(path))
|
|
42
|
+
data = JSON.parse(File.read(path))
|
|
43
|
+
Evilution::Session::Schema.validate!(data, source: path) if data.is_a?(Hash)
|
|
44
|
+
data
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def gc(older_than:)
|
|
@@ -60,6 +63,7 @@ class Evilution::Session::Store
|
|
|
60
63
|
|
|
61
64
|
def build_session_data(summary, now)
|
|
62
65
|
{
|
|
66
|
+
schema_version: Evilution::Session::Schema::CURRENT_VERSION,
|
|
63
67
|
version: Evilution::VERSION,
|
|
64
68
|
timestamp: now.iso8601,
|
|
65
69
|
git: git_context,
|
|
@@ -62,18 +62,27 @@ class Evilution::SpecAstCache
|
|
|
62
62
|
raise Evilution::ParseError.new("file not found: #{path}", file: path) unless File.exist?(path)
|
|
63
63
|
|
|
64
64
|
source = read_source(path)
|
|
65
|
+
result = parse_source(path, source)
|
|
66
|
+
collect_blocks(source, result, extract_comment_ranges(result))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_source(path, source)
|
|
65
70
|
result = Prism.parse(source)
|
|
71
|
+
return result unless result.failure?
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
73
|
+
raise Evilution::ParseError.new(
|
|
74
|
+
"failed to parse #{path}: #{result.errors.map(&:message).join(", ")}",
|
|
75
|
+
file: path
|
|
76
|
+
)
|
|
77
|
+
end
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
def extract_comment_ranges(result)
|
|
80
|
+
result.comments
|
|
81
|
+
.map { |c| c.location.start_offset...c.location.end_offset }
|
|
82
|
+
.sort_by(&:begin)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def collect_blocks(source, result, comment_ranges)
|
|
77
86
|
collector = BlockCollector.new(source, comment_ranges)
|
|
78
87
|
collector.visit(result.value)
|
|
79
88
|
collector.blocks
|
|
@@ -133,16 +142,21 @@ class Evilution::SpecAstCache
|
|
|
133
142
|
def strip_comments(slice, base_offset)
|
|
134
143
|
return slice if @comment_ranges.empty?
|
|
135
144
|
|
|
136
|
-
|
|
145
|
+
end_offset = base_offset + slice.bytesize
|
|
146
|
+
ranges = comment_ranges_within(base_offset, end_offset)
|
|
137
147
|
return slice if ranges.empty?
|
|
138
148
|
|
|
149
|
+
splice_excluding_ranges(base_offset, end_offset, ranges)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def splice_excluding_ranges(start_off, end_off, ranges)
|
|
139
153
|
result = +""
|
|
140
|
-
cursor =
|
|
154
|
+
cursor = start_off
|
|
141
155
|
ranges.each do |range|
|
|
142
156
|
result << @source.byteslice(cursor, range.begin - cursor)
|
|
143
157
|
cursor = range.end
|
|
144
158
|
end
|
|
145
|
-
result << @source.byteslice(cursor,
|
|
159
|
+
result << @source.byteslice(cursor, end_off - cursor)
|
|
146
160
|
result
|
|
147
161
|
end
|
|
148
162
|
|
data/lib/evilution/version.rb
CHANGED
data/lib/evilution.rb
CHANGED
|
@@ -94,6 +94,8 @@ require_relative "evilution/mutator/operator/equality_to_identity"
|
|
|
94
94
|
require_relative "evilution/mutator/operator/lambda_body"
|
|
95
95
|
require_relative "evilution/mutator/operator/begin_unwrap"
|
|
96
96
|
require_relative "evilution/mutator/operator/block_param_removal"
|
|
97
|
+
require_relative "evilution/mutator/operator/last_expression_removal"
|
|
98
|
+
require_relative "evilution/mutator/operator/argument_method_call_replacement"
|
|
97
99
|
require_relative "evilution/mutator/registry"
|
|
98
100
|
require_relative "evilution/equivalent"
|
|
99
101
|
require_relative "evilution/equivalent/heuristic"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/marinazzio/evilution/blob/master/schema/evilution.config.schema.json",
|
|
4
|
+
"title": "Evilution Configuration",
|
|
5
|
+
"description": "Schema for .evilution.yml / config/evilution.yml. Declaring `schema_version` opts the file into strict validation at load time.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": {
|
|
10
|
+
"type": "integer",
|
|
11
|
+
"enum": [1],
|
|
12
|
+
"description": "Config schema version. Currently must be 1. Declaring it enables strict validation: unknown keys are rejected and a future schema_version is refused."
|
|
13
|
+
},
|
|
14
|
+
"timeout": {
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"minimum": 1,
|
|
17
|
+
"default": 30,
|
|
18
|
+
"description": "Per-mutation timeout in seconds."
|
|
19
|
+
},
|
|
20
|
+
"format": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": ["text", "json", "html"],
|
|
23
|
+
"default": "text",
|
|
24
|
+
"description": "Output format."
|
|
25
|
+
},
|
|
26
|
+
"target": {
|
|
27
|
+
"type": ["string", "null"],
|
|
28
|
+
"default": null,
|
|
29
|
+
"description": "Filter expression: method (Foo#bar), class (Foo), namespace wildcard (Foo*), method-type selector (Foo# / Foo.), descendants (descendants:Foo), or source glob (source:**/*.rb)."
|
|
30
|
+
},
|
|
31
|
+
"min_score": {
|
|
32
|
+
"type": "number",
|
|
33
|
+
"minimum": 0.0,
|
|
34
|
+
"maximum": 1.0,
|
|
35
|
+
"default": 0.0,
|
|
36
|
+
"description": "Minimum mutation score (0.0–1.0) for exit code 0."
|
|
37
|
+
},
|
|
38
|
+
"integration": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"enum": ["rspec", "minitest"],
|
|
41
|
+
"default": "rspec",
|
|
42
|
+
"description": "Test framework integration."
|
|
43
|
+
},
|
|
44
|
+
"verbose": {
|
|
45
|
+
"type": "boolean",
|
|
46
|
+
"default": false,
|
|
47
|
+
"description": "Verbose output (RSS/GC stats per phase, error details for errored mutations)."
|
|
48
|
+
},
|
|
49
|
+
"quiet": {
|
|
50
|
+
"type": "boolean",
|
|
51
|
+
"default": false,
|
|
52
|
+
"description": "Suppress output."
|
|
53
|
+
},
|
|
54
|
+
"jobs": {
|
|
55
|
+
"type": "integer",
|
|
56
|
+
"minimum": 1,
|
|
57
|
+
"default": 1,
|
|
58
|
+
"description": "Number of parallel workers."
|
|
59
|
+
},
|
|
60
|
+
"fail_fast": {
|
|
61
|
+
"type": ["integer", "null"],
|
|
62
|
+
"minimum": 1,
|
|
63
|
+
"default": null,
|
|
64
|
+
"description": "Stop after N surviving mutants. null or omitted = disabled."
|
|
65
|
+
},
|
|
66
|
+
"baseline": {
|
|
67
|
+
"type": "boolean",
|
|
68
|
+
"default": true,
|
|
69
|
+
"description": "Run baseline test suite first to detect pre-existing failures and mark those mutations :neutral."
|
|
70
|
+
},
|
|
71
|
+
"isolation": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": ["auto", "fork", "in_process"],
|
|
74
|
+
"default": "auto",
|
|
75
|
+
"description": "Isolation strategy. auto selects fork for Rails projects."
|
|
76
|
+
},
|
|
77
|
+
"incremental": {
|
|
78
|
+
"type": "boolean",
|
|
79
|
+
"default": false,
|
|
80
|
+
"description": "Cache killed/timeout results across runs and skip them when source is unchanged."
|
|
81
|
+
},
|
|
82
|
+
"suggest_tests": {
|
|
83
|
+
"type": "boolean",
|
|
84
|
+
"default": false,
|
|
85
|
+
"description": "Generate concrete test code in survivor suggestions (RSpec or Minitest, matching integration)."
|
|
86
|
+
},
|
|
87
|
+
"progress": {
|
|
88
|
+
"type": "boolean",
|
|
89
|
+
"default": true,
|
|
90
|
+
"description": "TTY progress bar."
|
|
91
|
+
},
|
|
92
|
+
"save_session": {
|
|
93
|
+
"type": "boolean",
|
|
94
|
+
"default": false,
|
|
95
|
+
"description": "Save session JSON under .evilution/results/."
|
|
96
|
+
},
|
|
97
|
+
"line_ranges": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"default": {},
|
|
100
|
+
"description": "Per-file line-range constraints. Typically set via CLI; rare in YAML.",
|
|
101
|
+
"additionalProperties": true
|
|
102
|
+
},
|
|
103
|
+
"spec_files": {
|
|
104
|
+
"type": "array",
|
|
105
|
+
"items": { "type": "string" },
|
|
106
|
+
"default": [],
|
|
107
|
+
"description": "Explicit spec files to run. Bypasses auto-detection when non-empty."
|
|
108
|
+
},
|
|
109
|
+
"ignore_patterns": {
|
|
110
|
+
"type": "array",
|
|
111
|
+
"items": { "type": "string" },
|
|
112
|
+
"default": [],
|
|
113
|
+
"description": "AST patterns to skip during mutation generation. See docs/ast_pattern_syntax.md."
|
|
114
|
+
},
|
|
115
|
+
"show_disabled": {
|
|
116
|
+
"type": "boolean",
|
|
117
|
+
"default": false,
|
|
118
|
+
"description": "Report mutations skipped by `# evilution:disable` comments."
|
|
119
|
+
},
|
|
120
|
+
"baseline_session": {
|
|
121
|
+
"type": ["string", "null"],
|
|
122
|
+
"default": null,
|
|
123
|
+
"description": "Saved session file path for HTML report comparison."
|
|
124
|
+
},
|
|
125
|
+
"skip_heredoc_literals": {
|
|
126
|
+
"type": "boolean",
|
|
127
|
+
"default": false,
|
|
128
|
+
"description": "Skip all string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have coverage)."
|
|
129
|
+
},
|
|
130
|
+
"related_specs_heuristic": {
|
|
131
|
+
"type": "boolean",
|
|
132
|
+
"default": false,
|
|
133
|
+
"description": "When a mutation removes an `includes(...)` call, also run matching specs from spec/{requests,integration,features,system}."
|
|
134
|
+
},
|
|
135
|
+
"fallback_to_full_suite": {
|
|
136
|
+
"type": "boolean",
|
|
137
|
+
"default": false,
|
|
138
|
+
"description": "When no matching spec resolves, run the entire suite instead of marking the mutation :unresolved."
|
|
139
|
+
},
|
|
140
|
+
"preload": {
|
|
141
|
+
"type": ["string", "boolean", "null"],
|
|
142
|
+
"default": null,
|
|
143
|
+
"description": "Path to preload before forking workers. false to disable. null to auto-detect spec/rails_helper.rb -> spec/spec_helper.rb -> test/test_helper.rb for Rails projects."
|
|
144
|
+
},
|
|
145
|
+
"spec_mappings": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"default": {},
|
|
148
|
+
"description": "Custom mapping from source file to spec file(s). Keys are source paths; values are spec path strings or arrays of spec paths.",
|
|
149
|
+
"additionalProperties": {
|
|
150
|
+
"oneOf": [
|
|
151
|
+
{ "type": "string" },
|
|
152
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"spec_pattern": {
|
|
157
|
+
"type": ["string", "null"],
|
|
158
|
+
"default": null,
|
|
159
|
+
"description": "Glob restricting resolved spec candidates to files matching this pattern."
|
|
160
|
+
},
|
|
161
|
+
"example_targeting": {
|
|
162
|
+
"type": "boolean",
|
|
163
|
+
"default": true,
|
|
164
|
+
"description": "Per-mutation example-level targeting via body-token scan."
|
|
165
|
+
},
|
|
166
|
+
"example_targeting_fallback": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"enum": ["full_file", "unresolved"],
|
|
169
|
+
"default": "full_file",
|
|
170
|
+
"description": "Behavior when targeting finds no matching example."
|
|
171
|
+
},
|
|
172
|
+
"example_targeting_cache": {
|
|
173
|
+
"type": "object",
|
|
174
|
+
"default": { "max_files": 50, "max_blocks": 10000 },
|
|
175
|
+
"additionalProperties": false,
|
|
176
|
+
"description": "LRU cache bounds for the spec AST parser that powers example targeting.",
|
|
177
|
+
"properties": {
|
|
178
|
+
"max_files": { "type": "integer", "minimum": 1 },
|
|
179
|
+
"max_blocks": { "type": "integer", "minimum": 1 }
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"quiet_children": {
|
|
183
|
+
"type": "boolean",
|
|
184
|
+
"default": false,
|
|
185
|
+
"description": "Redirect each worker's stdout/stderr to per-pid files under quiet_children_dir."
|
|
186
|
+
},
|
|
187
|
+
"quiet_children_dir": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"default": "tmp/evilution_children",
|
|
190
|
+
"description": "Directory for --quiet-children per-pid log files."
|
|
191
|
+
},
|
|
192
|
+
"profile": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"enum": ["default", "strict"],
|
|
195
|
+
"default": "default",
|
|
196
|
+
"description": "Operator profile. strict adds aggressive truthiness mutators on top of default."
|
|
197
|
+
},
|
|
198
|
+
"hooks": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"default": {},
|
|
201
|
+
"additionalProperties": { "type": "string" },
|
|
202
|
+
"description": "Lifecycle hooks: keys are event names (e.g. worker_process_start, mutation_insert_pre); values are paths to Ruby files returning a Proc."
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|