evilution 0.13.0 → 0.15.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +17 -17
  4. data/CHANGELOG.md +39 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +73 -68
  7. data/lib/evilution/ast/source_surgeon.rb +7 -9
  8. data/lib/evilution/ast.rb +4 -0
  9. data/lib/evilution/baseline.rb +73 -75
  10. data/lib/evilution/cache.rb +75 -77
  11. data/lib/evilution/cli.rb +412 -173
  12. data/lib/evilution/config.rb +141 -136
  13. data/lib/evilution/equivalent/detector.rb +29 -27
  14. data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
  15. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  16. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  17. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  18. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  19. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  20. data/lib/evilution/equivalent/heuristic.rb +6 -0
  21. data/lib/evilution/equivalent.rb +4 -0
  22. data/lib/evilution/git/changed_files.rb +35 -37
  23. data/lib/evilution/git.rb +4 -0
  24. data/lib/evilution/integration/base.rb +5 -7
  25. data/lib/evilution/integration/rspec.rb +114 -116
  26. data/lib/evilution/integration.rb +4 -0
  27. data/lib/evilution/isolation/fork.rb +98 -100
  28. data/lib/evilution/isolation/in_process.rb +59 -61
  29. data/lib/evilution/isolation.rb +4 -0
  30. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  31. data/lib/evilution/mcp/server.rb +12 -11
  32. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  33. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  34. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  35. data/lib/evilution/mcp.rb +4 -0
  36. data/lib/evilution/memory/leak_check.rb +80 -84
  37. data/lib/evilution/memory.rb +34 -36
  38. data/lib/evilution/mutation.rb +40 -42
  39. data/lib/evilution/mutator/base.rb +62 -48
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  41. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  42. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  43. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  44. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  45. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  46. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  47. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  48. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  49. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  50. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  51. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  52. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  53. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  54. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  55. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  56. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  57. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  58. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  59. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  60. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  61. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  62. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  63. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  64. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  65. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  66. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  67. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  68. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  69. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  70. data/lib/evilution/mutator/operator.rb +6 -0
  71. data/lib/evilution/mutator/registry.rb +56 -56
  72. data/lib/evilution/mutator.rb +4 -0
  73. data/lib/evilution/parallel/pool.rb +56 -58
  74. data/lib/evilution/parallel.rb +4 -0
  75. data/lib/evilution/reporter/cli.rb +99 -101
  76. data/lib/evilution/reporter/html.rb +242 -244
  77. data/lib/evilution/reporter/json.rb +57 -59
  78. data/lib/evilution/reporter/suggestion.rb +354 -328
  79. data/lib/evilution/reporter.rb +4 -0
  80. data/lib/evilution/result/mutation_result.rb +43 -46
  81. data/lib/evilution/result/summary.rb +80 -81
  82. data/lib/evilution/result.rb +4 -0
  83. data/lib/evilution/runner.rb +401 -316
  84. data/lib/evilution/session/store.rb +147 -0
  85. data/lib/evilution/session.rb +4 -0
  86. data/lib/evilution/spec_resolver.rb +49 -47
  87. data/lib/evilution/subject.rb +14 -16
  88. data/lib/evilution/version.rb +1 -1
  89. data/lib/evilution.rb +16 -0
  90. metadata +24 -2
@@ -1,65 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- class Registry
6
- def self.default
7
- registry = new
8
- [
9
- Operator::ComparisonReplacement,
10
- Operator::ArithmeticReplacement,
11
- Operator::BooleanOperatorReplacement,
12
- Operator::BooleanLiteralReplacement,
13
- Operator::NilReplacement,
14
- Operator::IntegerLiteral,
15
- Operator::FloatLiteral,
16
- Operator::StringLiteral,
17
- Operator::ArrayLiteral,
18
- Operator::HashLiteral,
19
- Operator::SymbolLiteral,
20
- Operator::ConditionalNegation,
21
- Operator::ConditionalBranch,
22
- Operator::StatementDeletion,
23
- Operator::MethodBodyReplacement,
24
- Operator::NegationInsertion,
25
- Operator::ReturnValueRemoval,
26
- Operator::CollectionReplacement,
27
- Operator::MethodCallRemoval,
28
- Operator::ArgumentRemoval,
29
- Operator::BlockRemoval,
30
- Operator::ConditionalFlip,
31
- Operator::RangeReplacement,
32
- Operator::RegexpMutation,
33
- Operator::ReceiverReplacement,
34
- Operator::SendMutation,
35
- Operator::ArgumentNilSubstitution,
36
- Operator::CompoundAssignment
37
- ].each { |op| registry.register(op) }
38
- registry
39
- end
3
+ require_relative "../mutator"
40
4
 
41
- def initialize
42
- @operators = []
43
- end
44
-
45
- def register(operator_class)
46
- @operators << operator_class
47
- self
48
- end
5
+ class Evilution::Mutator::Registry
6
+ def self.default
7
+ registry = new
8
+ [
9
+ Evilution::Mutator::Operator::ComparisonReplacement,
10
+ Evilution::Mutator::Operator::ArithmeticReplacement,
11
+ Evilution::Mutator::Operator::BooleanOperatorReplacement,
12
+ Evilution::Mutator::Operator::BooleanLiteralReplacement,
13
+ Evilution::Mutator::Operator::NilReplacement,
14
+ Evilution::Mutator::Operator::IntegerLiteral,
15
+ Evilution::Mutator::Operator::FloatLiteral,
16
+ Evilution::Mutator::Operator::StringLiteral,
17
+ Evilution::Mutator::Operator::ArrayLiteral,
18
+ Evilution::Mutator::Operator::HashLiteral,
19
+ Evilution::Mutator::Operator::SymbolLiteral,
20
+ Evilution::Mutator::Operator::ConditionalNegation,
21
+ Evilution::Mutator::Operator::ConditionalBranch,
22
+ Evilution::Mutator::Operator::StatementDeletion,
23
+ Evilution::Mutator::Operator::MethodBodyReplacement,
24
+ Evilution::Mutator::Operator::NegationInsertion,
25
+ Evilution::Mutator::Operator::ReturnValueRemoval,
26
+ Evilution::Mutator::Operator::CollectionReplacement,
27
+ Evilution::Mutator::Operator::MethodCallRemoval,
28
+ Evilution::Mutator::Operator::ArgumentRemoval,
29
+ Evilution::Mutator::Operator::BlockRemoval,
30
+ Evilution::Mutator::Operator::ConditionalFlip,
31
+ Evilution::Mutator::Operator::RangeReplacement,
32
+ Evilution::Mutator::Operator::RegexpMutation,
33
+ Evilution::Mutator::Operator::ReceiverReplacement,
34
+ Evilution::Mutator::Operator::SendMutation,
35
+ Evilution::Mutator::Operator::ArgumentNilSubstitution,
36
+ Evilution::Mutator::Operator::CompoundAssignment,
37
+ Evilution::Mutator::Operator::MixinRemoval,
38
+ Evilution::Mutator::Operator::SuperclassRemoval
39
+ ].each { |op| registry.register(op) }
40
+ registry
41
+ end
49
42
 
50
- def mutations_for(subject)
51
- @operators.flat_map do |operator_class|
52
- operator_class.new.call(subject)
53
- end
54
- end
43
+ def initialize
44
+ @operators = []
45
+ end
55
46
 
56
- def operator_count
57
- @operators.length
58
- end
47
+ def register(operator_class)
48
+ @operators << operator_class
49
+ self
50
+ end
59
51
 
60
- def operators
61
- @operators.dup
62
- end
52
+ def mutations_for(subject)
53
+ @operators.flat_map do |operator_class|
54
+ operator_class.new.call(subject)
63
55
  end
64
56
  end
57
+
58
+ def operator_count
59
+ @operators.length
60
+ end
61
+
62
+ def operators
63
+ @operators.dup
64
+ end
65
65
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Mutator
4
+ end
@@ -1,63 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Parallel
5
- class Pool
6
- def initialize(size:)
7
- raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
8
-
9
- @size = size
10
- end
11
-
12
- def map(items, &block)
13
- results = []
14
-
15
- items.each_slice(@size) do |batch|
16
- results.concat(run_batch(batch, &block))
17
- end
18
-
19
- results
20
- end
21
-
22
- private
23
-
24
- def run_batch(items, &block)
25
- entries = items.map do |item|
26
- read_io, write_io = IO.pipe
27
- pid = fork_worker(item, read_io, write_io, &block)
28
- write_io.close
29
- { pid: pid, read_io: read_io }
30
- end
31
-
32
- collect_results(entries)
33
- end
34
-
35
- def fork_worker(item, read_io, write_io, &block)
36
- Process.fork do
37
- read_io.close
38
- result = block.call(item)
39
- Marshal.dump(result, write_io)
40
- rescue Exception => e # rubocop:disable Lint/RescueException
41
- Marshal.dump(e, write_io)
42
- ensure
43
- write_io.close
44
- exit!
45
- end
46
- end
47
-
48
- def collect_results(entries)
49
- entries.map do |entry|
50
- data = entry[:read_io].read
51
- entry[:read_io].close
52
- Process.wait(entry[:pid])
53
- raise Evilution::Error, "worker process failed with no result" if data.empty?
54
-
55
- result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
56
- raise result if result.is_a?(Exception)
57
-
58
- result
59
- end
60
- end
3
+ require_relative "../parallel"
4
+
5
+ class Evilution::Parallel::Pool
6
+ def initialize(size:)
7
+ raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
8
+
9
+ @size = size
10
+ end
11
+
12
+ def map(items, &block)
13
+ results = []
14
+
15
+ items.each_slice(@size) do |batch|
16
+ results.concat(run_batch(batch, &block))
17
+ end
18
+
19
+ results
20
+ end
21
+
22
+ private
23
+
24
+ def run_batch(items, &block)
25
+ entries = items.map do |item|
26
+ read_io, write_io = IO.pipe
27
+ pid = fork_worker(item, read_io, write_io, &block)
28
+ write_io.close
29
+ { pid: pid, read_io: read_io }
30
+ end
31
+
32
+ collect_results(entries)
33
+ end
34
+
35
+ def fork_worker(item, read_io, write_io, &block)
36
+ Process.fork do
37
+ read_io.close
38
+ result = block.call(item)
39
+ Marshal.dump(result, write_io)
40
+ rescue Exception => e # rubocop:disable Lint/RescueException
41
+ Marshal.dump(e, write_io)
42
+ ensure
43
+ write_io.close
44
+ exit!
45
+ end
46
+ end
47
+
48
+ def collect_results(entries)
49
+ entries.map do |entry|
50
+ data = entry[:read_io].read
51
+ entry[:read_io].close
52
+ Process.wait(entry[:pid])
53
+ raise Evilution::Error, "worker process failed with no result" if data.empty?
54
+
55
+ result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
56
+ raise result if result.is_a?(Exception)
57
+
58
+ result
61
59
  end
62
60
  end
63
61
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Parallel
4
+ end
@@ -1,105 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Reporter
5
- class CLI
6
- SEPARATOR = "=" * 44
7
-
8
- def call(summary)
9
- lines = []
10
- lines << header
11
- lines << SEPARATOR
12
- lines << ""
13
- lines << mutations_line(summary)
14
- lines << score_line(summary)
15
- lines << duration_line(summary)
16
- peak = summary.peak_memory_mb
17
- lines << peak_memory_line(peak) if peak
18
- append_survived(lines, summary)
19
- append_neutral(lines, summary)
20
- append_equivalent(lines, summary)
21
- lines << ""
22
- lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
23
- lines << result_line(summary)
24
-
25
- lines.join("\n")
26
- end
27
-
28
- private
29
-
30
- def append_survived(lines, summary)
31
- return unless summary.survived_results.any?
32
-
33
- lines << ""
34
- lines << "Survived mutations:"
35
- summary.survived_results.each { |result| lines << format_survived(result) }
36
- end
37
-
38
- def append_neutral(lines, summary)
39
- return unless summary.neutral_results.any?
40
-
41
- lines << ""
42
- lines << "Neutral mutations (test already failing):"
43
- summary.neutral_results.each { |result| lines << format_neutral(result) }
44
- end
45
-
46
- def append_equivalent(lines, summary)
47
- return unless summary.equivalent_results.any?
48
-
49
- lines << ""
50
- lines << "Equivalent mutations (provably identical behavior):"
51
- summary.equivalent_results.each { |result| lines << format_neutral(result) }
52
- end
53
-
54
- def header
55
- "Evilution v#{Evilution::VERSION} — Mutation Testing Results"
56
- end
57
-
58
- def mutations_line(summary)
59
- parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
60
- "#{summary.survived} survived, #{summary.timed_out} timed out"
61
- parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
62
- parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
63
- parts
64
- end
65
-
66
- def score_line(summary)
67
- denominator = summary.total - summary.errors - summary.neutral - summary.equivalent
68
- score_pct = format_pct(summary.score)
69
- "Score: #{score_pct} (#{summary.killed}/#{denominator})"
70
- end
71
-
72
- def duration_line(summary)
73
- "Duration: #{format("%.2f", summary.duration)}s"
74
- end
75
-
76
- def format_survived(result)
77
- mutation = result.mutation
78
- location = "#{mutation.file_path}:#{mutation.line}"
79
- diff_lines = mutation.diff.split("\n").map { |l| " #{l}" }.join("\n")
80
- " #{mutation.operator_name}: #{location}\n#{diff_lines}"
81
- end
82
-
83
- def format_neutral(result)
84
- mutation = result.mutation
85
- " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
86
- end
87
-
88
- def result_line(summary)
89
- min_score = 0.8
90
- pass_fail = summary.success?(min_score: min_score) ? "PASS" : "FAIL"
91
- score_pct = format_pct(summary.score)
92
- threshold_pct = format_pct(min_score)
93
- "Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
94
- end
95
-
96
- def peak_memory_line(peak_mb)
97
- format("Peak memory: %<mb>.1f MB", mb: peak_mb)
98
- end
99
-
100
- def format_pct(value)
101
- format("%.2f%%", value * 100)
102
- end
103
- end
3
+ require_relative "../reporter"
4
+
5
+ class Evilution::Reporter::CLI
6
+ SEPARATOR = "=" * 44
7
+
8
+ def call(summary)
9
+ lines = []
10
+ lines << header
11
+ lines << SEPARATOR
12
+ lines << ""
13
+ lines << mutations_line(summary)
14
+ lines << score_line(summary)
15
+ lines << duration_line(summary)
16
+ peak = summary.peak_memory_mb
17
+ lines << peak_memory_line(peak) if peak
18
+ append_survived(lines, summary)
19
+ append_neutral(lines, summary)
20
+ append_equivalent(lines, summary)
21
+ lines << ""
22
+ lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
23
+ lines << result_line(summary)
24
+
25
+ lines.join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def append_survived(lines, summary)
31
+ return unless summary.survived_results.any?
32
+
33
+ lines << ""
34
+ lines << "Survived mutations:"
35
+ summary.survived_results.each { |result| lines << format_survived(result) }
36
+ end
37
+
38
+ def append_neutral(lines, summary)
39
+ return unless summary.neutral_results.any?
40
+
41
+ lines << ""
42
+ lines << "Neutral mutations (test already failing):"
43
+ summary.neutral_results.each { |result| lines << format_neutral(result) }
44
+ end
45
+
46
+ def append_equivalent(lines, summary)
47
+ return unless summary.equivalent_results.any?
48
+
49
+ lines << ""
50
+ lines << "Equivalent mutations (provably identical behavior):"
51
+ summary.equivalent_results.each { |result| lines << format_neutral(result) }
52
+ end
53
+
54
+ def header
55
+ "Evilution v#{Evilution::VERSION} — Mutation Testing Results"
56
+ end
57
+
58
+ def mutations_line(summary)
59
+ parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
60
+ "#{summary.survived} survived, #{summary.timed_out} timed out"
61
+ parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
62
+ parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
63
+ parts
64
+ end
65
+
66
+ def score_line(summary)
67
+ denominator = summary.total - summary.errors - summary.neutral - summary.equivalent
68
+ score_pct = format_pct(summary.score)
69
+ "Score: #{score_pct} (#{summary.killed}/#{denominator})"
70
+ end
71
+
72
+ def duration_line(summary)
73
+ "Duration: #{format("%.2f", summary.duration)}s"
74
+ end
75
+
76
+ def format_survived(result)
77
+ mutation = result.mutation
78
+ location = "#{mutation.file_path}:#{mutation.line}"
79
+ diff_lines = mutation.diff.split("\n").map { |l| " #{l}" }.join("\n")
80
+ " #{mutation.operator_name}: #{location}\n#{diff_lines}"
81
+ end
82
+
83
+ def format_neutral(result)
84
+ mutation = result.mutation
85
+ " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
86
+ end
87
+
88
+ def result_line(summary)
89
+ min_score = 0.8
90
+ pass_fail = summary.success?(min_score: min_score) ? "PASS" : "FAIL"
91
+ score_pct = format_pct(summary.score)
92
+ threshold_pct = format_pct(min_score)
93
+ "Result: #{pass_fail} (score #{score_pct} #{pass_fail == "PASS" ? ">=" : "<"} #{threshold_pct})"
94
+ end
95
+
96
+ def peak_memory_line(peak_mb)
97
+ format("Peak memory: %<mb>.1f MB", mb: peak_mb)
98
+ end
99
+
100
+ def format_pct(value)
101
+ format("%.2f%%", value * 100)
104
102
  end
105
103
  end