evilution 0.29.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +54 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +42 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/heredoc_span.rb +99 -0
  8. data/lib/evilution/baseline.rb +15 -2
  9. data/lib/evilution/cli/commands/compare.rb +13 -0
  10. data/lib/evilution/cli/parser/command_extractor.rb +3 -1
  11. data/lib/evilution/cli/parser/options_builder.rb +2 -2
  12. data/lib/evilution/config/file_loader.rb +40 -1
  13. data/lib/evilution/config.rb +11 -1
  14. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  15. data/lib/evilution/feedback/setup_warning.rb +79 -0
  16. data/lib/evilution/gem_detector.rb +132 -0
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  18. data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
  19. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  20. data/lib/evilution/integration/minitest.rb +37 -2
  21. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  22. data/lib/evilution/integration/rspec.rb +16 -1
  23. data/lib/evilution/isolation/fork.rb +77 -10
  24. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  25. data/lib/evilution/mcp/info_tool.rb +3 -1
  26. data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
  27. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  28. data/lib/evilution/mcp/mutate_tool.rb +22 -3
  29. data/lib/evilution/mcp/session_tool.rb +7 -4
  30. data/lib/evilution/mcp.rb +6 -0
  31. data/lib/evilution/mutation.rb +13 -1
  32. data/lib/evilution/mutator/base.rb +49 -1
  33. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  34. data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
  35. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
  36. data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
  37. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  38. data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
  39. data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
  40. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  41. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  42. data/lib/evilution/mutator/registry.rb +2 -0
  43. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  44. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  45. data/lib/evilution/reporter/json.rb +2 -0
  46. data/lib/evilution/result/mutation_result.rb +12 -6
  47. data/lib/evilution/runner/baseline_runner.rb +5 -1
  48. data/lib/evilution/runner/isolation_resolver.rb +69 -8
  49. data/lib/evilution/runner/mutation_planner.rb +18 -1
  50. data/lib/evilution/session/schema.rb +44 -0
  51. data/lib/evilution/session/store.rb +5 -1
  52. data/lib/evilution/version.rb +1 -1
  53. data/lib/evilution.rb +2 -0
  54. data/schema/evilution.config.schema.json +205 -0
  55. data/script/build_runtime_snapshot +88 -0
  56. data/script/run_self_baseline +79 -0
  57. data/script/run_self_validation +54 -0
  58. metadata +15 -2
@@ -14,8 +14,31 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
14
14
  super
15
15
  end
16
16
 
17
+ # KeywordHashNode wraps call-arg kwargs + `**splat`. When an explicit
18
+ # `k: v` precedes a `**opts` splat in the same call, demoting `**opts` to
19
+ # bare `opts` puts a positional after a keyword and Ruby rejects it
20
+ # (`bar(k: v, opts)` is a syntax error). Mark such splats so
21
+ # `visit_assoc_splat_node` skips them. Splats that come BEFORE any kwarg
22
+ # (`bar(**opts, k: v)`) are still safe — positional-before-keyword is fine.
23
+ def visit_keyword_hash_node(node)
24
+ seen_kwarg = false
25
+ node.elements.each do |el|
26
+ if el.is_a?(Prism::AssocSplatNode) && seen_kwarg
27
+ kwarg_preceded_splats.add(el)
28
+ elsif el.is_a?(Prism::AssocNode)
29
+ seen_kwarg = true
30
+ end
31
+ end
32
+
33
+ super
34
+ end
35
+
17
36
  def visit_assoc_splat_node(node)
18
- mutate_remove_double_splat(node) if node.value && !hash_elements.include?(node)
37
+ return super if node.value.nil?
38
+ return super if hash_elements.include?(node)
39
+ return super if kwarg_preceded_splats.include?(node)
40
+
41
+ mutate_remove_double_splat(node)
19
42
 
20
43
  super
21
44
  end
@@ -26,6 +49,10 @@ class Evilution::Mutator::Operator::SplatOperator < Evilution::Mutator::Base
26
49
  @hash_elements ||= Set.new.compare_by_identity
27
50
  end
28
51
 
52
+ def kwarg_preceded_splats
53
+ @kwarg_preceded_splats ||= Set.new.compare_by_identity
54
+ end
55
+
29
56
  def mutate_remove_splat(node)
30
57
  add_mutation(
31
58
  offset: node.location.start_offset,
@@ -9,9 +9,62 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
9
9
  end
10
10
 
11
11
  def visit_interpolated_string_node(node)
12
- return super unless node.heredoc?
13
- return if @skip_heredoc_literals
12
+ if node.heredoc?
13
+ return if @skip_heredoc_literals
14
14
 
15
+ node.parts.each do |part|
16
+ next if part.is_a?(Prism::StringNode)
17
+
18
+ visit(part)
19
+ end
20
+ return
21
+ end
22
+
23
+ # Adjacent-string concatenation — both `"foo" "bar"` and the line-continued
24
+ # form `"foo" \\\n "bar"` — lands in an InterpolatedStringNode whose parts
25
+ # are all StringNodes. Mutating chunks individually splices the wrong span:
26
+ # for the continued form Ruby treats `nil \\\n "rest"` as a confusing
27
+ # parse rather than a clean nil; for both forms the result is one StringNode
28
+ # plus an orphaned adjacent literal, not a meaningful mutation of the whole
29
+ # expression. Replace the entire concatenation in one shot instead.
30
+ if adjacent_string_concat?(node)
31
+ emit_string_mutations(node)
32
+ return
33
+ end
34
+
35
+ super
36
+ end
37
+
38
+ def visit_string_node(node)
39
+ return super if node.heredoc?
40
+
41
+ emit_string_mutations(node)
42
+
43
+ super
44
+ end
45
+
46
+ # Inner StringNode chunks of an interpolated symbol (`:"visit_#{type}"`),
47
+ # interpolated regular expression (`/^#{needle}/`), or interpolated x-string
48
+ # (`` `echo #{cmd}` ``) are not free string literals — they are fragments of
49
+ # a different literal kind. Mutating them splices empty-string bytes into the
50
+ # middle of a `:"..."` / `/.../` / `` `...` `` token, producing unparseable
51
+ # code. Visit only the interpolation parts (which may contain mutatable
52
+ # expressions); skip the raw StringNode chunks.
53
+ def visit_interpolated_symbol_node(node)
54
+ visit_non_string_parts(node)
55
+ end
56
+
57
+ def visit_interpolated_regular_expression_node(node)
58
+ visit_non_string_parts(node)
59
+ end
60
+
61
+ def visit_interpolated_x_string_node(node)
62
+ visit_non_string_parts(node)
63
+ end
64
+
65
+ private
66
+
67
+ def visit_non_string_parts(node)
15
68
  node.parts.each do |part|
16
69
  next if part.is_a?(Prism::StringNode)
17
70
 
@@ -19,10 +72,30 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
19
72
  end
20
73
  end
21
74
 
22
- def visit_string_node(node)
23
- return super if node.heredoc?
75
+ # Adjacent-string concatenation differs from a single interpolated string by
76
+ # the shape of its parts: each adjacent chunk is a full quoted literal of its
77
+ # own — a StringNode or InterpolatedStringNode that owns its own opening
78
+ # quote (`opening_loc`). A plain interpolated string `"a #{x} b"` decomposes
79
+ # into chunk StringNodes (no `opening_loc`) interleaved with
80
+ # EmbeddedStatementsNode parts (whose `opening_loc` is the `#{` delimiter,
81
+ # not a quote). Requiring StringNode/InterpolatedStringNode AND `opening_loc`
82
+ # rejects pure-interpolation cases like `"#{a}#{b}"` (whose parts are all
83
+ # EmbeddedStatementsNodes) while accepting mixed plain+interpolated adjacency
84
+ # like `"foo" "bar #{x}"` and its line-continued cousin.
85
+ QUOTED_LITERAL_TYPES = [Prism::StringNode, Prism::InterpolatedStringNode].freeze
86
+ private_constant :QUOTED_LITERAL_TYPES
24
87
 
25
- replacement = node.content.empty? ? '"mutation"' : '""'
88
+ def adjacent_string_concat?(node)
89
+ return false unless node.parts.length > 1
90
+
91
+ node.parts.all? do |part|
92
+ QUOTED_LITERAL_TYPES.any? { |type| part.is_a?(type) } && part.opening_loc
93
+ end
94
+ end
95
+
96
+ def emit_string_mutations(node)
97
+ empty = node_content_empty?(node)
98
+ replacement = empty ? '"mutation"' : '""'
26
99
 
27
100
  add_mutation(
28
101
  offset: node.location.start_offset,
@@ -37,7 +110,11 @@ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
37
110
  replacement: "nil",
38
111
  node: node
39
112
  )
113
+ end
40
114
 
41
- super
115
+ def node_content_empty?(node)
116
+ return node.content.empty? if node.is_a?(Prism::StringNode)
117
+
118
+ node.parts.all? { |part| part.is_a?(Prism::StringNode) && part.content.empty? }
42
119
  end
43
120
  end
@@ -39,11 +39,13 @@ class Evilution::Mutator::Registry
39
39
  Evilution::Mutator::Operator::SymbolLiteral,
40
40
  Evilution::Mutator::Operator::ConditionalNegation,
41
41
  Evilution::Mutator::Operator::ConditionalBranch,
42
+ Evilution::Mutator::Operator::LastExpressionRemoval,
42
43
  Evilution::Mutator::Operator::StatementDeletion,
43
44
  Evilution::Mutator::Operator::MethodBodyReplacement,
44
45
  Evilution::Mutator::Operator::NegationInsertion,
45
46
  Evilution::Mutator::Operator::ReturnValueRemoval,
46
47
  Evilution::Mutator::Operator::CollectionReplacement,
48
+ Evilution::Mutator::Operator::ArgumentMethodCallReplacement,
47
49
  Evilution::Mutator::Operator::MethodCallRemoval,
48
50
  Evilution::Mutator::Operator::ArgumentRemoval,
49
51
  Evilution::Mutator::Operator::BlockRemoval,
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ # EV-nrgw / GH #1168: Score is `killed / score_denominator` where
6
+ # `score_denominator = total - errors - neutral - equivalent - unresolved -
7
+ # unparseable` — errors are excluded from the denominator entirely. A run
8
+ # can read PASS at 100% while 16 of 19 mutations silently errored. This
9
+ # formatter surfaces a warning right under the metrics block when the error
10
+ # rate crosses the threshold so the silent failure mode becomes loud.
11
+ class Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning
12
+ DEFAULT_THRESHOLD = 0.25
13
+
14
+ def initialize(threshold: DEFAULT_THRESHOLD)
15
+ @threshold = threshold
16
+ end
17
+
18
+ def format(summary)
19
+ return nil if summary.total.zero?
20
+ return nil if summary.errors.zero?
21
+
22
+ rate = summary.errors.to_f / summary.total
23
+ return nil if rate <= @threshold
24
+
25
+ pct = (rate * 100).round(1)
26
+ "! High error rate: #{summary.errors}/#{summary.total} (#{pct}%) mutations errored — " \
27
+ "score may be unreliable. See the \"Errored mutations:\" section for the underlying cause."
28
+ end
29
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../cli"
4
4
  require_relative "line_formatters/mutations"
5
5
  require_relative "line_formatters/score"
6
+ require_relative "line_formatters/error_rate_warning"
6
7
  require_relative "line_formatters/duration"
7
8
  require_relative "line_formatters/efficiency"
8
9
  require_relative "line_formatters/peak_memory"
@@ -11,6 +12,7 @@ class Evilution::Reporter::CLI::MetricsBlock
11
12
  DEFAULT_LINES = [
12
13
  Evilution::Reporter::CLI::LineFormatters::Mutations.new,
13
14
  Evilution::Reporter::CLI::LineFormatters::Score.new,
15
+ Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
14
16
  Evilution::Reporter::CLI::LineFormatters::Duration.new,
15
17
  Evilution::Reporter::CLI::LineFormatters::Efficiency.new,
16
18
  Evilution::Reporter::CLI::LineFormatters::PeakMemory.new
@@ -5,6 +5,7 @@ require "time"
5
5
  require_relative "suggestion"
6
6
 
7
7
  require_relative "../reporter"
8
+ require_relative "../session/schema"
8
9
 
9
10
  class Evilution::Reporter::JSON
10
11
  def initialize(suggest_tests: false, integration: :rspec)
@@ -19,6 +20,7 @@ class Evilution::Reporter::JSON
19
20
 
20
21
  def build_report(summary)
21
22
  report = {
23
+ schema_version: Evilution::Session::Schema::CURRENT_VERSION,
22
24
  version: Evilution::VERSION,
23
25
  timestamp: Time.now.iso8601,
24
26
  summary: build_summary(summary),
@@ -23,28 +23,34 @@ class Evilution::Result::MutationResult
23
23
  freeze
24
24
  end
25
25
 
26
+ # Positive type checks, not nil checks. EV-s5br / GH #1174: the
27
+ # nil_replacement mutator can swap a nil default into `false` (or some other
28
+ # non-typed value), and `nil?` then returns false, sending a missing method
29
+ # to the wrong receiver and crashing the parent worker. Asking the field
30
+ # explicitly whether it is the expected struct keeps the parent process
31
+ # alive and lets the mutation count as a measured (errored) result.
26
32
  def child_rss_kb
27
- @memory.nil? ? nil : @memory.child_rss_kb
33
+ @memory.is_a?(Evilution::Result::MemoryStats) ? @memory.child_rss_kb : nil
28
34
  end
29
35
 
30
36
  def memory_delta_kb
31
- @memory.nil? ? nil : @memory.memory_delta_kb
37
+ @memory.is_a?(Evilution::Result::MemoryStats) ? @memory.memory_delta_kb : nil
32
38
  end
33
39
 
34
40
  def parent_rss_kb
35
- @memory.nil? ? nil : @memory.parent_rss_kb
41
+ @memory.is_a?(Evilution::Result::MemoryStats) ? @memory.parent_rss_kb : nil
36
42
  end
37
43
 
38
44
  def error_message
39
- @error.nil? ? nil : @error.message
45
+ @error.is_a?(Evilution::Result::ErrorInfo) ? @error.message : nil
40
46
  end
41
47
 
42
48
  def error_class
43
- @error.nil? ? nil : @error.klass
49
+ @error.is_a?(Evilution::Result::ErrorInfo) ? @error.klass : nil
44
50
  end
45
51
 
46
52
  def error_backtrace
47
- @error.nil? ? nil : @error.backtrace
53
+ @error.is_a?(Evilution::Result::ErrorInfo) ? @error.backtrace : nil
48
54
  end
49
55
 
50
56
  def killed?
@@ -55,7 +55,11 @@ class Evilution::Runner::BaselineRunner
55
55
  return nil unless config.baseline? && subjects.any?
56
56
 
57
57
  log_start
58
- baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
58
+ baseline = Evilution::Baseline.new(
59
+ timeout: config.timeout,
60
+ test_files: config.spec_files.empty? ? nil : config.spec_files,
61
+ **integration_class.baseline_options
62
+ )
59
63
  result = baseline.call(subjects)
60
64
  log_complete(result)
61
65
  result
@@ -4,6 +4,7 @@ require_relative "../runner"
4
4
  require_relative "../isolation/fork"
5
5
  require_relative "../isolation/in_process"
6
6
  require_relative "../rails_detector"
7
+ require_relative "../gem_detector"
7
8
 
8
9
  class Evilution::Runner::IsolationResolver
9
10
  PRELOAD_CANDIDATES = [
@@ -108,18 +109,52 @@ class Evilution::Runner::IsolationResolver
108
109
  end
109
110
 
110
111
  def resolve_preload_path
111
- return resolve_explicit_preload(config.preload) if config.preload.is_a?(String)
112
+ return resolve_explicit_with_fallback(config.preload) if config.preload.is_a?(String)
112
113
 
113
114
  resolve_autodetected_preload
114
115
  end
115
116
 
116
- def resolve_explicit_preload(path)
117
- return path if File.file?(path)
117
+ # Explicit preload path resolution with auto-detect fallthrough under :fork.
118
+ # When the user-configured path is missing, surface a stderr warning naming
119
+ # the missing path and try the auto-detect chain so a stale .evilution.yml
120
+ # entry doesn't silently disable preloading. Fallthrough requires both
121
+ # :fork isolation AND a detected Rails root (otherwise the chain has nowhere
122
+ # to look and the explicit-missing error is raised directly).
123
+ def resolve_explicit_with_fallback(explicit)
124
+ return explicit if File.file?(explicit)
118
125
 
119
- raise Evilution::ConfigError.new("preload file not found: #{path.inspect}", file: path)
126
+ raise_explicit_preload_missing(explicit) unless can_fallthrough_to_autodetect?
127
+
128
+ warn_missing_explicit_preload(explicit)
129
+ fallback = find_first_existing_candidate
130
+ return fallback if fallback
131
+
132
+ raise build_combined_missing_error(explicit)
133
+ end
134
+
135
+ def can_fallthrough_to_autodetect?
136
+ resolve_isolation == :fork && !detected_rails_root.nil?
120
137
  end
121
138
 
122
139
  def resolve_autodetected_preload
140
+ if detected_rails_root
141
+ fallback = find_first_existing_candidate
142
+ return fallback if fallback
143
+
144
+ raise Evilution::ConfigError, autodetect_missing_message
145
+ end
146
+
147
+ detected_gem_entry
148
+ end
149
+
150
+ def detected_gem_entry
151
+ return @detected_gem_entry if defined?(@detected_gem_entry)
152
+
153
+ root = Evilution::GemDetector.gem_root_for_any(target_files)
154
+ @detected_gem_entry = root && Evilution::GemDetector.gem_entry_for(root, target_paths: target_files)
155
+ end
156
+
157
+ def find_first_existing_candidate
123
158
  root = detected_rails_root
124
159
  return nil unless root
125
160
 
@@ -127,11 +162,37 @@ class Evilution::Runner::IsolationResolver
127
162
  abs = File.join(root, rel)
128
163
  return abs if File.file?(abs)
129
164
  end
165
+ nil
166
+ end
167
+
168
+ def autodetect_missing_message
169
+ "Preload file not found. Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
170
+ "Pass --preload <file> or set preload: in .evilution.yml. " \
171
+ "Use --no-preload (or preload: false) to disable preloading entirely."
172
+ end
130
173
 
131
- raise Evilution::ConfigError,
132
- "Preload file not found. Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
133
- "Pass --preload <file> or set preload: in .evilution.yml. " \
134
- "Use --no-preload (or preload: false) to disable preloading entirely."
174
+ def build_combined_missing_error(explicit)
175
+ Evilution::ConfigError.new(
176
+ "Preload file not found. Configured preload #{explicit.inspect} does not exist, " \
177
+ "and none of the auto-detect candidates exist either. " \
178
+ "Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
179
+ "Pass --preload <file> or set preload: in .evilution.yml. " \
180
+ "Use --no-preload (or preload: false) to disable preloading entirely.",
181
+ file: explicit
182
+ )
183
+ end
184
+
185
+ def raise_explicit_preload_missing(path)
186
+ raise Evilution::ConfigError.new("preload file not found: #{path.inspect}", file: path)
187
+ end
188
+
189
+ def warn_missing_explicit_preload(path)
190
+ return if config.quiet
191
+
192
+ $stderr.write(
193
+ "[evilution] warning: configured preload #{path.inspect} not found; " \
194
+ "falling through to auto-detect chain.\n"
195
+ )
135
196
  end
136
197
 
137
198
  # When the user explicitly requests InProcess on a Rails project, warn once
@@ -27,7 +27,8 @@ class Evilution::Runner::MutationPlanner
27
27
 
28
28
  def call(subjects)
29
29
  generation = generate(subjects)
30
- disabled_filter = filter_disabled(generation.mutations)
30
+ deduped = deduplicate(generation.mutations)
31
+ disabled_filter = filter_disabled(deduped)
31
32
  disabled_mutations = compute_disabled_mutations(disabled_filter)
32
33
  sig_filter = filter_sig_blocks(disabled_filter.enabled)
33
34
  equivalent_filter = filter_equivalent(sig_filter.enabled)
@@ -59,6 +60,22 @@ class Evilution::Runner::MutationPlanner
59
60
  )
60
61
  end
61
62
 
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
78
+
62
79
  def generate(subjects)
63
80
  filter = build_ignore_filter
64
81
  operator_options = build_operator_options
@@ -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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.29.0"
4
+ VERSION = "0.30.0"
5
5
  end
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"