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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "../result"
|
|
4
4
|
|
|
5
5
|
class Evilution::Result::MutationResult
|
|
6
|
-
STATUSES = %i[killed survived timeout error neutral equivalent unresolved].freeze
|
|
6
|
+
STATUSES = %i[killed survived timeout error neutral equivalent unresolved unparseable].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :mutation, :status, :duration, :killing_test, :test_command,
|
|
9
9
|
:child_rss_kb, :memory_delta_kb, :parent_rss_kb,
|
|
@@ -58,4 +58,8 @@ class Evilution::Result::MutationResult
|
|
|
58
58
|
def unresolved?
|
|
59
59
|
status == :unresolved
|
|
60
60
|
end
|
|
61
|
+
|
|
62
|
+
def unparseable?
|
|
63
|
+
status == :unparseable
|
|
64
|
+
end
|
|
61
65
|
end
|
|
@@ -51,8 +51,16 @@ class Evilution::Result::Summary
|
|
|
51
51
|
results.count(&:unresolved?)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def unparseable
|
|
55
|
+
results.count(&:unparseable?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def score_denominator
|
|
59
|
+
total - errors - neutral - equivalent - unresolved - unparseable
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
def score
|
|
55
|
-
denominator =
|
|
63
|
+
denominator = score_denominator
|
|
56
64
|
return 0.0 if denominator.zero?
|
|
57
65
|
|
|
58
66
|
killed.to_f / denominator
|
|
@@ -82,6 +90,10 @@ class Evilution::Result::Summary
|
|
|
82
90
|
results.select(&:unresolved?)
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
def unparseable_results
|
|
94
|
+
results.select(&:unparseable?)
|
|
95
|
+
end
|
|
96
|
+
|
|
85
97
|
def coverage_gaps
|
|
86
98
|
Evilution::Result::CoverageGapGrouper.new.call(survived_results)
|
|
87
99
|
end
|
|
@@ -4,6 +4,9 @@ require_relative "../baseline"
|
|
|
4
4
|
require_relative "../spec_resolver"
|
|
5
5
|
require_relative "../integration/rspec"
|
|
6
6
|
require_relative "../integration/minitest"
|
|
7
|
+
require_relative "../example_filter"
|
|
8
|
+
require_relative "../spec_ast_cache"
|
|
9
|
+
require_relative "../source_ast_cache"
|
|
7
10
|
|
|
8
11
|
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
9
12
|
|
|
@@ -29,8 +32,16 @@ class Evilution::Runner::BaselineRunner
|
|
|
29
32
|
def build_integration
|
|
30
33
|
klass = integration_class
|
|
31
34
|
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
32
|
-
kwargs = {
|
|
33
|
-
|
|
35
|
+
kwargs = {
|
|
36
|
+
test_files: test_files,
|
|
37
|
+
hooks: hooks,
|
|
38
|
+
fallback_to_full_suite: config.fallback_to_full_suite?,
|
|
39
|
+
spec_selector: config.spec_selector
|
|
40
|
+
}
|
|
41
|
+
if klass == Evilution::Integration::RSpec
|
|
42
|
+
kwargs[:related_specs_heuristic] = config.related_specs_heuristic?
|
|
43
|
+
kwargs[:example_filter] = build_example_filter
|
|
44
|
+
end
|
|
34
45
|
klass.new(**kwargs)
|
|
35
46
|
end
|
|
36
47
|
|
|
@@ -56,6 +67,16 @@ class Evilution::Runner::BaselineRunner
|
|
|
56
67
|
|
|
57
68
|
attr_reader :config, :hooks
|
|
58
69
|
|
|
70
|
+
def build_example_filter
|
|
71
|
+
return nil unless config.example_targeting?
|
|
72
|
+
|
|
73
|
+
Evilution::ExampleFilter.new(
|
|
74
|
+
cache: Evilution::SpecAstCache.new(**config.example_targeting_cache),
|
|
75
|
+
fallback: config.example_targeting_fallback,
|
|
76
|
+
source_cache: Evilution::SourceAstCache.new
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
def log_start
|
|
60
81
|
return if config.quiet || !config.text? || !$stderr.tty?
|
|
61
82
|
|
|
@@ -30,10 +30,10 @@ class Evilution::Runner::IsolationResolver
|
|
|
30
30
|
|
|
31
31
|
def perform_preload
|
|
32
32
|
return if config.preload == false
|
|
33
|
-
return unless resolve_isolation == :fork
|
|
34
33
|
|
|
35
34
|
path = resolve_preload_path
|
|
36
35
|
return unless path
|
|
36
|
+
return unless should_preload?
|
|
37
37
|
|
|
38
38
|
prepare_load_path_for_preload
|
|
39
39
|
require File.expand_path(path)
|
|
@@ -48,6 +48,17 @@ class Evilution::Runner::IsolationResolver
|
|
|
48
48
|
|
|
49
49
|
attr_reader :config, :hooks
|
|
50
50
|
|
|
51
|
+
# Under :fork, allow preloading — caller resolves whether a path exists (an
|
|
52
|
+
# explicit --preload / preload: value, or an auto-detected rails_helper) and
|
|
53
|
+
# bails early when none does. Under :in_process, only allow preloading when
|
|
54
|
+
# the user explicitly asked via --preload or preload: in YAML — don't
|
|
55
|
+
# auto-load spec/rails_helper.rb for a user who opted out of fork.
|
|
56
|
+
def should_preload?
|
|
57
|
+
return true if resolve_isolation == :fork
|
|
58
|
+
|
|
59
|
+
config.preload.is_a?(String)
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
def target_files
|
|
52
63
|
@target_files ||= @target_files_callback.call
|
|
53
64
|
end
|
|
@@ -40,11 +40,9 @@ class Evilution::Runner::MutationExecutor
|
|
|
40
40
|
truncated = false
|
|
41
41
|
|
|
42
42
|
mutations.each_with_index do |mutation, index|
|
|
43
|
-
result =
|
|
44
|
-
test_command = ->(m) { integration.call(m) }
|
|
45
|
-
isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
46
|
-
end
|
|
43
|
+
result = execute_one(mutation, integration)
|
|
47
44
|
mutation.strip_sources!
|
|
45
|
+
result = neutralize_if_infra_error(result)
|
|
48
46
|
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
49
47
|
results << result
|
|
50
48
|
survived_count += 1 if result.survived?
|
|
@@ -109,6 +107,7 @@ class Evilution::Runner::MutationExecutor
|
|
|
109
107
|
|
|
110
108
|
def process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
111
109
|
batch_results.each do |result|
|
|
110
|
+
result = neutralize_if_infra_error(result)
|
|
112
111
|
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
113
112
|
state[:results] << result
|
|
114
113
|
state[:survived_count] += 1 if result.survived?
|
|
@@ -120,17 +119,56 @@ class Evilution::Runner::MutationExecutor
|
|
|
120
119
|
state[:truncated] = true if should_truncate?(state[:survived_count])
|
|
121
120
|
end
|
|
122
121
|
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
# Reclassify results as :neutral when the failure was caused by test
|
|
123
|
+
# infrastructure rather than by the mutation. Two independent paths:
|
|
124
|
+
#
|
|
125
|
+
# 1) :error from a missing require / spec_helper / rails_helper / spec/support
|
|
126
|
+
# initialization — detected by error_class ∈ INFRA_ERROR_CLASSES and
|
|
127
|
+
# first backtrace frame matching INFRA_BACKTRACE_PATHS. Origin-only match
|
|
128
|
+
# (not `any?`): Ruby backtraces typically carry spec_helper frames below
|
|
129
|
+
# mutation-caused errors, so matching any frame would misclassify real
|
|
130
|
+
# mutation NameError/LoadError as :neutral.
|
|
131
|
+
#
|
|
132
|
+
# 2) :killed from a CrashDetector test_crashed whose sole crash class is in
|
|
133
|
+
# INFRA_CRASH_CLASSES (ActiveRecord::StatementTimeout, Timeout::Error,
|
|
134
|
+
# etc.). These surface under parallel workers sharing a DB file or on a
|
|
135
|
+
# slow CI; fork.rb initially reports them as :killed, and without this
|
|
136
|
+
# demotion the kill count inflates with infra noise. No backtrace check:
|
|
137
|
+
# the single-class signal from CrashDetector already rules out mixed
|
|
138
|
+
# mutation-caused failures. See EV-toid / GH #814.
|
|
139
|
+
INFRA_ERROR_CLASSES = %w[LoadError NameError].freeze
|
|
140
|
+
INFRA_BACKTRACE_PATHS = %r{(?:^|/)(?:spec_helper\.rb|rails_helper\.rb|spec/support/)}
|
|
141
|
+
INFRA_CRASH_CLASSES = %w[
|
|
142
|
+
Timeout::Error
|
|
143
|
+
ActiveRecord::StatementTimeout
|
|
144
|
+
ActiveRecord::Deadlocked
|
|
145
|
+
ActiveRecord::ConnectionTimeoutError
|
|
146
|
+
ActiveRecord::LockWaitTimeout
|
|
147
|
+
SQLite3::BusyException
|
|
148
|
+
].freeze
|
|
149
|
+
private_constant :INFRA_ERROR_CLASSES, :INFRA_BACKTRACE_PATHS, :INFRA_CRASH_CLASSES
|
|
150
|
+
|
|
151
|
+
def neutralize_if_infra_error(result)
|
|
152
|
+
return neutralize(result) if infra_crash?(result)
|
|
153
|
+
return result unless result.error?
|
|
154
|
+
return result unless INFRA_ERROR_CLASSES.include?(result.error_class)
|
|
155
|
+
return result unless infra_origin?(result.error_backtrace)
|
|
156
|
+
|
|
157
|
+
neutralize(result)
|
|
158
|
+
end
|
|
125
159
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return
|
|
160
|
+
def infra_crash?(result)
|
|
161
|
+
result.killed? && INFRA_CRASH_CLASSES.include?(result.error_class)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def infra_origin?(backtrace)
|
|
165
|
+
frames = Array(backtrace)
|
|
166
|
+
return false if frames.empty?
|
|
167
|
+
|
|
168
|
+
frames.first =~ INFRA_BACKTRACE_PATHS ? true : false
|
|
169
|
+
end
|
|
133
170
|
|
|
171
|
+
def neutralize(result)
|
|
134
172
|
Evilution::Result::MutationResult.new(
|
|
135
173
|
mutation: result.mutation,
|
|
136
174
|
status: :neutral,
|
|
@@ -145,6 +183,20 @@ class Evilution::Runner::MutationExecutor
|
|
|
145
183
|
)
|
|
146
184
|
end
|
|
147
185
|
|
|
186
|
+
def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
187
|
+
return result unless result.survived? && baseline_result && baseline_result.failed?
|
|
188
|
+
|
|
189
|
+
if config.spec_files.any?
|
|
190
|
+
should_neutralize = true
|
|
191
|
+
else
|
|
192
|
+
spec_file = spec_resolver.call(result.mutation.file_path) || baseline_runner.neutralization_fallback_dir
|
|
193
|
+
should_neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
194
|
+
end
|
|
195
|
+
return result unless should_neutralize
|
|
196
|
+
|
|
197
|
+
neutralize(result)
|
|
198
|
+
end
|
|
199
|
+
|
|
148
200
|
def compact_result(result)
|
|
149
201
|
{
|
|
150
202
|
status: result.status,
|
|
@@ -187,6 +239,11 @@ class Evilution::Runner::MutationExecutor
|
|
|
187
239
|
cached_results = {}
|
|
188
240
|
|
|
189
241
|
batch.each_with_index do |mutation, i|
|
|
242
|
+
if mutation.unparseable?
|
|
243
|
+
cached_results[i] = compact_result(build_unparseable_result(mutation))
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
|
|
190
247
|
cached = fetch_cached_result(mutation)
|
|
191
248
|
if cached
|
|
192
249
|
cached_results[i] = compact_result(cached)
|
|
@@ -198,6 +255,19 @@ class Evilution::Runner::MutationExecutor
|
|
|
198
255
|
[uncached_indices, cached_results]
|
|
199
256
|
end
|
|
200
257
|
|
|
258
|
+
def execute_one(mutation, integration)
|
|
259
|
+
return build_unparseable_result(mutation) if mutation.unparseable?
|
|
260
|
+
|
|
261
|
+
execute_or_fetch(mutation) do
|
|
262
|
+
test_command = ->(m) { integration.call(m) }
|
|
263
|
+
isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def build_unparseable_result(mutation)
|
|
268
|
+
Evilution::Result::MutationResult.new(mutation: mutation, status: :unparseable)
|
|
269
|
+
end
|
|
270
|
+
|
|
201
271
|
def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
202
272
|
result_map = cached_results.dup
|
|
203
273
|
uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
|
|
@@ -20,13 +20,7 @@ class Evilution::Runner::SubjectPipeline
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def target_files
|
|
23
|
-
@target_files ||=
|
|
24
|
-
resolve_source_glob
|
|
25
|
-
elsif !config.target_files.empty?
|
|
26
|
-
config.target_files
|
|
27
|
-
else
|
|
28
|
-
Evilution::Git::ChangedFiles.new.call
|
|
29
|
-
end
|
|
23
|
+
@target_files ||= resolve_target_files
|
|
30
24
|
end
|
|
31
25
|
|
|
32
26
|
private
|
|
@@ -88,11 +82,27 @@ class Evilution::Runner::SubjectPipeline
|
|
|
88
82
|
|
|
89
83
|
def filter_by_target(subjects)
|
|
90
84
|
matched = subjects.select(&target_matcher)
|
|
91
|
-
raise Evilution::Error,
|
|
85
|
+
raise Evilution::Error, build_no_match_error if matched.empty?
|
|
92
86
|
|
|
93
87
|
matched
|
|
94
88
|
end
|
|
95
89
|
|
|
90
|
+
def resolve_target_files
|
|
91
|
+
return resolve_source_glob if source_glob_target?
|
|
92
|
+
return config.target_files unless config.target_files.empty?
|
|
93
|
+
|
|
94
|
+
@used_git_fallback = true
|
|
95
|
+
Evilution::Git::ChangedFiles.new.call
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_no_match_error
|
|
99
|
+
base = "no subject matched '#{config.target}'"
|
|
100
|
+
return base unless @used_git_fallback
|
|
101
|
+
|
|
102
|
+
"#{base}; scanned git-changed files only. Pass file paths or " \
|
|
103
|
+
"--target source:<glob> to scan the full codebase."
|
|
104
|
+
end
|
|
105
|
+
|
|
96
106
|
def target_matcher
|
|
97
107
|
target = config.target
|
|
98
108
|
if target.end_with?("*")
|
data/lib/evilution/runner.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "parallel/pool"
|
|
|
21
21
|
require_relative "session/store"
|
|
22
22
|
require_relative "temp_dir_tracker"
|
|
23
23
|
require_relative "rails_detector"
|
|
24
|
+
require_relative "parallel_db_warning"
|
|
24
25
|
require_relative "runner/subject_pipeline"
|
|
25
26
|
require_relative "runner/mutation_planner"
|
|
26
27
|
require_relative "runner/isolation_resolver"
|
|
@@ -43,6 +44,7 @@ class Evilution::Runner
|
|
|
43
44
|
|
|
44
45
|
def call
|
|
45
46
|
install_signal_handlers
|
|
47
|
+
emit_parallel_db_warning
|
|
46
48
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
47
49
|
|
|
48
50
|
subjects = subject_pipeline.call
|
|
@@ -150,6 +152,10 @@ class Evilution::Runner
|
|
|
150
152
|
mutation_executor.call(mutations, baseline_result)
|
|
151
153
|
end
|
|
152
154
|
|
|
155
|
+
def emit_parallel_db_warning
|
|
156
|
+
Evilution::ParallelDbWarning.warn_if_sqlite_parallel(config)
|
|
157
|
+
end
|
|
158
|
+
|
|
153
159
|
def install_signal_handlers
|
|
154
160
|
%w[INT TERM].each { |sig| install_signal_handler(sig) }
|
|
155
161
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "../evilution"
|
|
6
|
+
|
|
7
|
+
# Content-hash-keyed LRU of Prism::ParseResult. Different source bytes always
|
|
8
|
+
# yield a different key, so the cache is valid for the lifetime of the process.
|
|
9
|
+
class Evilution::SourceAstCache
|
|
10
|
+
DEFAULT_MAX_ENTRIES = 50
|
|
11
|
+
private_constant :DEFAULT_MAX_ENTRIES
|
|
12
|
+
|
|
13
|
+
def initialize(max_entries: DEFAULT_MAX_ENTRIES)
|
|
14
|
+
@max_entries = max_entries
|
|
15
|
+
@entries = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(source)
|
|
19
|
+
return Prism.parse(source) if @max_entries <= 0
|
|
20
|
+
|
|
21
|
+
key = Digest::SHA256.digest(source)
|
|
22
|
+
if @entries.key?(key)
|
|
23
|
+
result = @entries.delete(key)
|
|
24
|
+
@entries[key] = result
|
|
25
|
+
return result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
result = Prism.parse(source)
|
|
29
|
+
@entries[key] = result
|
|
30
|
+
evict_until_within_bounds
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def evict_until_within_bounds
|
|
37
|
+
@entries.shift while @entries.length > @max_entries
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../evilution"
|
|
5
|
+
|
|
6
|
+
class Evilution::SpecAstCache
|
|
7
|
+
Block = Struct.new(:kind, :line, :end_line, :body_text)
|
|
8
|
+
|
|
9
|
+
BLOCK_METHODS = %i[
|
|
10
|
+
describe context fcontext xcontext
|
|
11
|
+
it fit xit specify
|
|
12
|
+
before after
|
|
13
|
+
].freeze
|
|
14
|
+
private_constant :BLOCK_METHODS
|
|
15
|
+
|
|
16
|
+
DEFAULT_MAX_FILES = 50
|
|
17
|
+
DEFAULT_MAX_BLOCKS = 10_000
|
|
18
|
+
private_constant :DEFAULT_MAX_FILES, :DEFAULT_MAX_BLOCKS
|
|
19
|
+
|
|
20
|
+
def initialize(max_files: DEFAULT_MAX_FILES, max_blocks: DEFAULT_MAX_BLOCKS)
|
|
21
|
+
@max_files = max_files
|
|
22
|
+
@max_blocks = max_blocks
|
|
23
|
+
@entries = {}
|
|
24
|
+
@total_blocks = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch(path)
|
|
28
|
+
if @entries.key?(path)
|
|
29
|
+
blocks = @entries.delete(path)
|
|
30
|
+
@entries[path] = blocks
|
|
31
|
+
return blocks
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
blocks = parse(path)
|
|
35
|
+
insert(path, blocks)
|
|
36
|
+
blocks
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cached?(path)
|
|
40
|
+
@entries.key?(path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def insert(path, blocks)
|
|
46
|
+
@entries[path] = blocks
|
|
47
|
+
@total_blocks += blocks.length
|
|
48
|
+
evict_until_within_bounds
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def evict_until_within_bounds
|
|
52
|
+
while @entries.length > @max_files || @total_blocks > @max_blocks
|
|
53
|
+
break if @entries.empty?
|
|
54
|
+
|
|
55
|
+
oldest_path = @entries.keys.first
|
|
56
|
+
evicted = @entries.delete(oldest_path)
|
|
57
|
+
@total_blocks -= evicted.length
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse(path)
|
|
62
|
+
raise Evilution::ParseError.new("file not found: #{path}", file: path) unless File.exist?(path)
|
|
63
|
+
|
|
64
|
+
source = read_source(path)
|
|
65
|
+
result = Prism.parse(source)
|
|
66
|
+
|
|
67
|
+
if result.failure?
|
|
68
|
+
raise Evilution::ParseError.new(
|
|
69
|
+
"failed to parse #{path}: #{result.errors.map(&:message).join(", ")}",
|
|
70
|
+
file: path
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
comment_ranges = result.comments
|
|
75
|
+
.map { |c| c.location.start_offset...c.location.end_offset }
|
|
76
|
+
.sort_by(&:begin)
|
|
77
|
+
collector = BlockCollector.new(source, comment_ranges)
|
|
78
|
+
collector.visit(result.value)
|
|
79
|
+
collector.blocks
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_source(path)
|
|
83
|
+
File.read(path)
|
|
84
|
+
rescue SystemCallError => e
|
|
85
|
+
raise Evilution::ParseError.new("cannot read #{path}: #{e.message}", file: path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class BlockCollector < Prism::Visitor
|
|
89
|
+
attr_reader :blocks
|
|
90
|
+
|
|
91
|
+
def initialize(source, comment_ranges)
|
|
92
|
+
@source = source
|
|
93
|
+
@comment_ranges = comment_ranges
|
|
94
|
+
@blocks = []
|
|
95
|
+
super()
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def visit_call_node(node)
|
|
99
|
+
@blocks << build_block(node) if block_method?(node)
|
|
100
|
+
super
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def block_method?(node)
|
|
106
|
+
BLOCK_METHODS.include?(node.name) && node.block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_block(node)
|
|
110
|
+
location = node.location
|
|
111
|
+
Block.new(
|
|
112
|
+
node.name,
|
|
113
|
+
location.start_line,
|
|
114
|
+
location.end_line,
|
|
115
|
+
extract_body_text(node)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_body_text(node)
|
|
120
|
+
block = node.block
|
|
121
|
+
return "" unless block
|
|
122
|
+
|
|
123
|
+
body = block.body
|
|
124
|
+
return "" unless body
|
|
125
|
+
|
|
126
|
+
start_off = body.location.start_offset
|
|
127
|
+
end_off = body.location.end_offset
|
|
128
|
+
slice = @source.byteslice(start_off, end_off - start_off) || ""
|
|
129
|
+
stripped = strip_comments(slice, start_off)
|
|
130
|
+
stripped.downcase
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def strip_comments(slice, base_offset)
|
|
134
|
+
return slice if @comment_ranges.empty?
|
|
135
|
+
|
|
136
|
+
ranges = comment_ranges_within(base_offset, base_offset + slice.bytesize)
|
|
137
|
+
return slice if ranges.empty?
|
|
138
|
+
|
|
139
|
+
result = +""
|
|
140
|
+
cursor = base_offset
|
|
141
|
+
ranges.each do |range|
|
|
142
|
+
result << @source.byteslice(cursor, range.begin - cursor)
|
|
143
|
+
cursor = range.end
|
|
144
|
+
end
|
|
145
|
+
result << @source.byteslice(cursor, base_offset + slice.bytesize - cursor)
|
|
146
|
+
result
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def comment_ranges_within(start_off, end_off)
|
|
150
|
+
lower = @comment_ranges.bsearch_index { |r| r.begin >= start_off }
|
|
151
|
+
return [] unless lower
|
|
152
|
+
|
|
153
|
+
result = []
|
|
154
|
+
idx = lower
|
|
155
|
+
while idx < @comment_ranges.length
|
|
156
|
+
range = @comment_ranges[idx]
|
|
157
|
+
break if range.begin >= end_off
|
|
158
|
+
|
|
159
|
+
result << range if range.end <= end_off
|
|
160
|
+
idx += 1
|
|
161
|
+
end
|
|
162
|
+
result
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
private_constant :BlockCollector
|
|
166
|
+
end
|
|
@@ -10,11 +10,12 @@ class Evilution::SpecResolver
|
|
|
10
10
|
@request_dir = request_dir
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def call(source_path)
|
|
13
|
+
def call(source_path, spec_pattern: nil)
|
|
14
14
|
return nil if source_path.nil? || source_path.empty?
|
|
15
15
|
|
|
16
16
|
normalized = normalize_path(source_path)
|
|
17
17
|
candidates = candidate_test_paths(normalized)
|
|
18
|
+
candidates = filter_by_pattern(candidates, spec_pattern) if spec_pattern
|
|
18
19
|
candidates.find { |path| File.exist?(path) }
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -24,6 +25,10 @@ class Evilution::SpecResolver
|
|
|
24
25
|
|
|
25
26
|
private
|
|
26
27
|
|
|
28
|
+
def filter_by_pattern(candidates, pattern)
|
|
29
|
+
candidates.select { |path| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
30
|
+
end
|
|
31
|
+
|
|
27
32
|
def normalize_path(path)
|
|
28
33
|
path = path.delete_prefix("./")
|
|
29
34
|
path = path.delete_prefix("#{Dir.pwd}/") if path.start_with?("/")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "spec_resolver"
|
|
4
|
+
|
|
5
|
+
class Evilution::SpecSelector
|
|
6
|
+
def initialize(spec_files: [], spec_mappings: {}, spec_pattern: nil, spec_resolver: Evilution::SpecResolver.new)
|
|
7
|
+
@spec_files = Array(spec_files)
|
|
8
|
+
@spec_mappings = spec_mappings || {}
|
|
9
|
+
@spec_pattern = spec_pattern
|
|
10
|
+
@spec_resolver = spec_resolver
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(source_path)
|
|
14
|
+
return @spec_files unless @spec_files.empty?
|
|
15
|
+
|
|
16
|
+
mapped = mapping_for(source_path)
|
|
17
|
+
if mapped
|
|
18
|
+
existing = mapped.select { |path| File.exist?(path) }
|
|
19
|
+
return existing unless existing.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
resolved = @spec_resolver.call(source_path, spec_pattern: @spec_pattern)
|
|
23
|
+
resolved ? [resolved] : nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def mapping_for(source_path)
|
|
29
|
+
@spec_mappings[normalize(source_path)]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def normalize(path)
|
|
33
|
+
return path if path.nil?
|
|
34
|
+
|
|
35
|
+
normalized = path.to_s
|
|
36
|
+
normalized = normalized.delete_prefix("#{Dir.pwd}/") if normalized.start_with?("/")
|
|
37
|
+
normalized.delete_prefix("./")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -21,9 +21,15 @@ module Evilution::TempDirTracker
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def self.cleanup_all
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
# Trap-safe: Signal.trap handlers forbid Monitor#synchronize, so both the
|
|
25
|
+
# snapshot and the per-dir tracking removal fall back to a lock-free path
|
|
26
|
+
# when ThreadError is raised. Successful removals drop the entry from
|
|
27
|
+
# @dirs; failures stay tracked so a later cleanup can retry.
|
|
28
|
+
snapshot_tracked_dirs.each do |d|
|
|
29
|
+
FileUtils.rm_rf(d)
|
|
30
|
+
remove_from_tracking(d)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
27
33
|
end
|
|
28
34
|
end
|
|
29
35
|
|
|
@@ -36,4 +42,18 @@ module Evilution::TempDirTracker
|
|
|
36
42
|
@at_exit_registered = true
|
|
37
43
|
end
|
|
38
44
|
private_class_method :register_at_exit
|
|
45
|
+
|
|
46
|
+
def self.snapshot_tracked_dirs
|
|
47
|
+
@monitor.synchronize { @dirs.to_a }
|
|
48
|
+
rescue ThreadError
|
|
49
|
+
@dirs.to_a
|
|
50
|
+
end
|
|
51
|
+
private_class_method :snapshot_tracked_dirs
|
|
52
|
+
|
|
53
|
+
def self.remove_from_tracking(dir)
|
|
54
|
+
@monitor.synchronize { @dirs.delete(dir) }
|
|
55
|
+
rescue ThreadError
|
|
56
|
+
@dirs.delete(dir)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :remove_from_tracking
|
|
39
59
|
end
|
data/lib/evilution/version.rb
CHANGED
data/script/memory_check
CHANGED
|
@@ -7,8 +7,8 @@ require_relative "../lib/evilution/integration/rspec"
|
|
|
7
7
|
|
|
8
8
|
FIXTURE = File.expand_path("../spec/support/fixtures/simple_class.rb", __dir__)
|
|
9
9
|
FIXTURE_SPEC = File.expand_path("../spec/support/fixtures/simple_class_spec.rb", __dir__)
|
|
10
|
-
COMPLEX_FIXTURE = File.expand_path("../
|
|
11
|
-
COMPLEX_FIXTURE_SPEC = File.expand_path("../spec/
|
|
10
|
+
COMPLEX_FIXTURE = File.expand_path("../spec/support/fixtures/memory_check/target.rb", __dir__)
|
|
11
|
+
COMPLEX_FIXTURE_SPEC = File.expand_path("../spec/support/fixtures/memory_check/target_examples.rb", __dir__)
|
|
12
12
|
ITERATIONS = Integer(ENV.fetch("MEMORY_CHECK_ITERATIONS", 50))
|
|
13
13
|
MAX_GROWTH_KB = Integer(ENV.fetch("MEMORY_CHECK_MAX_GROWTH_KB", 10_240))
|
|
14
14
|
|
|
@@ -95,8 +95,10 @@ if mutations.size >= 2
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
# 5. RSpec integration per-mutation with complex fixture
|
|
98
|
-
# Uses
|
|
99
|
-
#
|
|
98
|
+
# Uses a frozen snapshot under spec/support/fixtures/memory_check/ for
|
|
99
|
+
# realistic per-mutation load: deep spec nesting, heavy metadata. Snapshot
|
|
100
|
+
# is intentional - decoupling the budget from project file growth so this
|
|
101
|
+
# check measures integration plumbing memory, not project size.
|
|
100
102
|
complex_parser = Evilution::AST::Parser.new
|
|
101
103
|
complex_registry = Evilution::Mutator::Registry.default
|
|
102
104
|
complex_subjects = complex_parser.call(COMPLEX_FIXTURE)
|
|
@@ -105,7 +107,7 @@ complex_mutations = complex_subjects.flat_map { |s| complex_registry.mutations_f
|
|
|
105
107
|
integration = Evilution::Integration::RSpec.new(test_files: [COMPLEX_FIXTURE_SPEC])
|
|
106
108
|
|
|
107
109
|
# Budget is generous: per-mutation require() adds the file to $LOADED_FEATURES and
|
|
108
|
-
# accumulates constant-redefinition warnings. Local runs land around 20
|
|
110
|
+
# accumulates constant-redefinition warnings. Local runs land around 20 MB; CI
|
|
109
111
|
# varies up to ~30 MB depending on Ruby build and GC pressure, so we leave headroom.
|
|
110
112
|
all_passed &= run_check("RSpec integration per-mutation (Config)", iterations: 20, max_growth_kb: 40_960) do
|
|
111
113
|
mutation = complex_mutations.sample
|