evilution 1.3.0 → 1.5.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "regexp_parser"
5
+
6
+ require_relative "../ast"
7
+
8
+ # The pattern of a regexp literal, parsed with regexp_parser and addressed by
9
+ # byte offsets into the file, for operators that mutate a regexp's structure.
10
+ #
11
+ # Edits are made by offsets into the original source, never by re-emitting the
12
+ # pattern, so everything outside an edit stays byte for byte as written.
13
+ #
14
+ # regexp_parser counts offsets in characters; they are converted to bytes here
15
+ # so they can go straight to source surgery. Prism accepts patterns whose
16
+ # references point nowhere (`/(?:a)\1/`), so an edit has to be compiled before
17
+ # it is emitted: `#compiles?` does that.
18
+ class Evilution::AST::RegexpPattern
19
+ Token = Data.define(:type, :token, :text, :start_offset, :end_offset)
20
+
21
+ attr_reader :tokens
22
+
23
+ # Returns nil for anything but a plain regexp literal — interpolated
24
+ # patterns are only known at runtime — and when regexp_parser cannot read
25
+ # the pattern (including one that is not valid in its encoding), so callers
26
+ # simply emit no mutants.
27
+ def self.parse(node)
28
+ return nil unless node.is_a?(Prism::RegularExpressionNode)
29
+
30
+ new(node)
31
+ rescue Regexp::Parser::Error
32
+ nil
33
+ end
34
+
35
+ def initialize(node)
36
+ @text = node.content_loc.slice
37
+ @base_offset = node.content_loc.start_offset
38
+ @options = options_of(node)
39
+ @byte_offsets = byte_offsets_of(@text)
40
+ @tokens = scan_tokens
41
+ @root = Regexp::Parser.parse(@text, options: @options)
42
+ end
43
+
44
+ # Yields every expression of the parsed pattern with its start and end
45
+ # offsets in the file.
46
+ def each_expression
47
+ @root.each_expression do |expression, _index|
48
+ yield expression, *offsets(expression)
49
+ end
50
+ end
51
+
52
+ # An expression's start and end offsets in the file, quantifier included.
53
+ def offsets(expression)
54
+ [file_offset(expression.ts), file_offset(expression.ts + expression.full_length)]
55
+ end
56
+
57
+ # Whether the pattern still compiles once the span between the two file
58
+ # offsets is replaced.
59
+ def compiles?(start_offset, end_offset, replacement)
60
+ local_start = start_offset - @base_offset
61
+ mutated = @text.byteslice(0, local_start) + replacement + @text.byteslice((end_offset - @base_offset)..)
62
+ without_warnings { Regexp.new(mutated, @options) }
63
+ true
64
+ rescue RegexpError
65
+ false
66
+ end
67
+
68
+ private
69
+
70
+ # Only extended mode changes how a pattern is read: whitespace and `#`
71
+ # comments become tokens of their own. Case-insensitivity and multiline
72
+ # change what a pattern matches, not how it parses or whether it compiles.
73
+ def options_of(node)
74
+ node.extended? ? Regexp::EXTENDED : 0
75
+ end
76
+
77
+ # Bytes before each character position, one entry past the last character.
78
+ def byte_offsets_of(text)
79
+ offsets = [0]
80
+ text.each_char { |char| offsets << (offsets.last + char.bytesize) }
81
+ offsets
82
+ end
83
+
84
+ def file_offset(character_index)
85
+ @base_offset + @byte_offsets.fetch(character_index)
86
+ end
87
+
88
+ def scan_tokens
89
+ Regexp::Scanner.scan(@text, options: @options).map do |type, token, text, start_index, end_index|
90
+ Token.new(type: type, token: token, text: text,
91
+ start_offset: file_offset(start_index), end_offset: file_offset(end_index))
92
+ end
93
+ end
94
+
95
+ # Onigmo warns about patterns such as duplicated class ranges; a mutant can
96
+ # produce them, and the warning would land in the user's output.
97
+ def without_warnings
98
+ verbose = $VERBOSE
99
+ $VERBOSE = nil
100
+ yield
101
+ ensure
102
+ $VERBOSE = verbose
103
+ end
104
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # The tie-breaking mode of a `round` call: the `:even` of
7
+ # `amount.round(2, half: :even)`. Only a literal mode Ruby accepts counts; a
8
+ # mode held in a variable, `nil` or an unknown symbol is not one.
9
+ module Evilution::AST::RoundHalfMode
10
+ MODES = %i[up even down].freeze
11
+
12
+ # The symbol node holding the mode, or nil when node is not a `round` call
13
+ # with a literal mode.
14
+ def self.of(node)
15
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :round
16
+
17
+ value = half_value(node.arguments)
18
+ value if value.is_a?(Prism::SymbolNode) && MODES.include?(value.unescaped.to_sym)
19
+ end
20
+
21
+ # The value given to `half:`, whatever it is.
22
+ def self.half_value(arguments)
23
+ return nil unless arguments
24
+
25
+ keywords = arguments.arguments.grep(Prism::KeywordHashNode).flat_map(&:elements)
26
+ pair = keywords.find { |element| half_key?(element) }
27
+ pair.value if pair
28
+ end
29
+
30
+ def self.half_key?(element)
31
+ return false unless element.is_a?(Prism::AssocNode)
32
+
33
+ key = element.key
34
+ key.is_a?(Prism::SymbolNode) && key.unescaped == "half"
35
+ end
36
+
37
+ private_class_method :half_value, :half_key?
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "literal_callable"
6
+
7
+ # An ActiveRecord scope declared with a literal body:
8
+ # `scope :recent, -> { where(recent: true) }`, or with `lambda { }` /
9
+ # `proc { }`. The body is what mutations go into; the name is the class
10
+ # method the declaration defines.
11
+ #
12
+ # Only the receiver-less form written directly in a class body counts: that
13
+ # is the call the mutated file re-runs to replace the scope, so the mutated
14
+ # body takes effect. A scope whose body is an object or a variable has no
15
+ # body of its own to mutate.
16
+ module Evilution::AST::ScopeDeclaration
17
+ # The scope declarations among a class body's direct statements.
18
+ def self.in_body(body)
19
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
20
+ return [] unless statements
21
+
22
+ statements.body.select { |node| body_of(node) }
23
+ end
24
+
25
+ # The node holding the scope's body -- a LambdaNode, or the BlockNode of a
26
+ # `lambda` / `proc` call -- or nil when node is not a scope declaration.
27
+ def self.body_of(node)
28
+ return nil unless scope_call?(node)
29
+
30
+ Evilution::AST::LiteralCallable.body_of(node.arguments.arguments[1])
31
+ end
32
+
33
+ def self.scope_name(node)
34
+ node.arguments.arguments.first.unescaped
35
+ end
36
+
37
+ def self.scope_call?(node)
38
+ return false unless node.is_a?(Prism::CallNode) && node.name == :scope && node.receiver.nil?
39
+
40
+ args = node.arguments ? node.arguments.arguments : []
41
+ args.length == 2 && args.first.is_a?(Prism::SymbolNode)
42
+ end
43
+
44
+ private_class_method :scope_call?
45
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "prism"
4
-
5
3
  require_relative "../ast"
4
+ require_relative "syntax_check"
6
5
 
7
6
  module Evilution::AST::SourceSurgeon
8
7
  Result = Struct.new(:source, :status, keyword_init: true) do
@@ -19,7 +18,7 @@ module Evilution::AST::SourceSurgeon
19
18
  binary = source.b
20
19
  binary[offset, length] = replacement.b
21
20
  mutated = binary.force_encoding(source.encoding)
22
- status = Prism.parse(mutated).success? ? :ok : :unparseable
21
+ status = Evilution::AST::SyntaxCheck.valid?(mutated) ? :ok : :unparseable
23
22
  Result.new(source: mutated, status: status).freeze
24
23
  end
25
24
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../ast"
6
+
7
+ # Answers whether mutated source is Ruby the running interpreter will accept.
8
+ #
9
+ # Mutations are built and screened with Prism, but Prism and the older
10
+ # parse.y parser do not agree on everything: Prism takes a bare constant
11
+ # operator write inside a method (`def f; LIMIT ||= 1; end`) and a `nil[...]`
12
+ # pattern, both of which parse.y rejects. An interpreter that compiles with
13
+ # parse.y -- Ruby 3.3 by default, any later one started with
14
+ # `--parser=parse.y` -- would be handed a mutant marked parseable and raise
15
+ # SyntaxError evaluating it.
16
+ #
17
+ # So Prism decides first, and where the interpreter does not compile with
18
+ # Prism, parse.y is asked as well. That second parse costs about what the
19
+ # first does and is only paid on those interpreters.
20
+ module Evilution::AST::SyntaxCheck
21
+ # How the interpreter says it compiles: `ruby 3.4.1 (...) +PRISM [...]`.
22
+ PRISM_INTERPRETER = RUBY_DESCRIPTION.include?("+PRISM")
23
+
24
+ def self.valid?(source, prism_interpreter: PRISM_INTERPRETER)
25
+ return false unless Prism.parse(source).success?
26
+
27
+ prism_interpreter || parse_y_accepts?(source)
28
+ end
29
+
30
+ # RubyVM::AbstractSyntaxTree is built on parse.y whatever the interpreter
31
+ # compiles with. It is CRuby's own: another implementation has no parse.y
32
+ # to ask, and there Prism's answer stands, as it does after a failure that
33
+ # is not about syntax.
34
+ def self.parse_y_accepts?(source)
35
+ return true unless defined?(RubyVM::AbstractSyntaxTree)
36
+
37
+ without_warnings { RubyVM::AbstractSyntaxTree.parse(source) }
38
+ true
39
+ rescue SyntaxError
40
+ false
41
+ rescue StandardError
42
+ true
43
+ end
44
+
45
+ # parse.y warns as it parses ("possibly useless use of == in void
46
+ # context"), which a mutant earns far more often than written code does.
47
+ def self.without_warnings
48
+ verbose = $VERBOSE
49
+ $VERBOSE = nil
50
+ yield
51
+ ensure
52
+ $VERBOSE = verbose
53
+ end
54
+
55
+ private_class_method :parse_y_accepts?, :without_warnings
56
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "included_block"
6
+
7
+ # The lines of a file that hold code no subject covers: statements written in
8
+ # a class, module or `class << self` body, or at the top level, outside every
9
+ # method -- callback macros, DSL calls, logical writes on a constant -- or in a
10
+ # concern's `included` or `prepended` block, which is a class body written
11
+ # elsewhere. Mutations are generated per subject, so a run aimed at these
12
+ # lines generates none of its own, and saying so is the only way a reader can
13
+ # tell "nothing to mutate" from "nothing reached".
14
+ #
15
+ # Visibility keywords without arguments (`private`) carry no behaviour of
16
+ # their own and are left out, as are `require` and `require_relative`: loading
17
+ # a file is not something a mutation of this one could test.
18
+ #
19
+ # A few operators do reach class-body statements -- an `alias_method`, an
20
+ # `include` -- through the first method of the scope. A statement holding a
21
+ # line a mutation was generated for is targeted after all, and is left out
22
+ # whole.
23
+ module Evilution::AST::UncoveredCode
24
+ SCOPE_NODES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
25
+ VISIBILITY_KEYWORDS = %i[private protected public module_function].freeze
26
+ LOAD_METHODS = %i[require require_relative].freeze
27
+ private_constant :SCOPE_NODES, :VISIBILITY_KEYWORDS, :LOAD_METHODS
28
+
29
+ # subjects: those parsed from file_path; lines: the Range to keep, or nil
30
+ # for the whole file; mutated_lines: the lines of file_path that mutations
31
+ # were generated for. Returns the uncovered lines as merged Ranges, in
32
+ # source order.
33
+ def self.call(file_path, subjects, lines:, mutated_lines: [])
34
+ covered = subjects.flat_map { |subject| subject_lines(subject) }.to_set
35
+ uncovered = code_lines(File.read(file_path), mutated_lines).reject { |line| covered.include?(line) }
36
+ uncovered = uncovered.select { |line| lines.cover?(line) } if lines
37
+ merge(uncovered.uniq)
38
+ end
39
+
40
+ def self.code_lines(source, mutated_lines)
41
+ statements(Prism.parse(source).value.statements)
42
+ .reject { |node| mutated_lines.any? { |line| line.between?(node.start_line, node.end_line) } }
43
+ .flat_map { |node| node_lines(node) }
44
+ end
45
+
46
+ def self.statements(body)
47
+ nodes = case body
48
+ when Prism::StatementsNode then body.body
49
+ when Prism::BeginNode then body.statements ? body.statements.body : []
50
+ else []
51
+ end
52
+ nodes.flat_map { |node| expand(node) }
53
+ end
54
+
55
+ def self.expand(node)
56
+ return statements(node.body) if SCOPE_NODES.any? { |type| node.is_a?(type) }
57
+
58
+ hook = Evilution::AST::IncludedBlock.of(node) || Evilution::AST::IncludedBlock.prepended(node)
59
+ return statements(hook.body) if hook
60
+ return [] if bare_visibility?(node) || load_call?(node)
61
+
62
+ [node]
63
+ end
64
+
65
+ def self.load_call?(node)
66
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && LOAD_METHODS.include?(node.name)
67
+ end
68
+
69
+ def self.bare_visibility?(node)
70
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments.nil? &&
71
+ VISIBILITY_KEYWORDS.include?(node.name)
72
+ end
73
+
74
+ def self.node_lines(node)
75
+ (node.start_line..node.end_line).to_a
76
+ end
77
+
78
+ def self.subject_lines(subject)
79
+ (subject.line_number..(subject.line_number + subject.source.count("\n"))).to_a
80
+ end
81
+
82
+ def self.merge(lines)
83
+ lines.slice_when { |a, b| b != a + 1 }.map { |run| run.first..run.last }
84
+ end
85
+
86
+ private_class_method :code_lines, :statements, :expand, :bare_visibility?, :load_call?, :node_lines,
87
+ :subject_lines, :merge
88
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # A value-object definition: `Data.define(...)` or `Struct.new(...)` called on
7
+ # the core class itself (`Data` or `::Data`, not `Foo::Data`), with or without
8
+ # a block.
9
+ module Evilution::AST::ValueObjectDefinition
10
+ DEFINERS = { Data: :define, Struct: :new }.freeze
11
+
12
+ def self.match?(node)
13
+ return false unless node.is_a?(Prism::CallNode) && core_constant?(node.receiver)
14
+
15
+ DEFINERS[node.receiver.name] == node.name
16
+ end
17
+
18
+ def self.core_constant?(node)
19
+ return true if node.is_a?(Prism::ConstantReadNode)
20
+
21
+ node.is_a?(Prism::ConstantPathNode) && node.parent.nil?
22
+ end
23
+
24
+ private_class_method :core_constant?
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../baseline"
4
+
5
+ # The lines that say why a spec file was red in the baseline, shared by the
6
+ # warning printed during the run and the note in the final report.
7
+ class Evilution::Baseline::FailureFormatter
8
+ NO_DETAIL = "no failure detail was captured"
9
+
10
+ def call(failure)
11
+ lines = error_lines(failure) + failure.examples.map { |example| example_line(example) }
12
+ remainder = failure.example_count - failure.examples.length
13
+ lines << "... and #{remainder} more failing" if remainder.positive?
14
+ lines.empty? ? [NO_DETAIL] : lines
15
+ end
16
+
17
+ private
18
+
19
+ def error_lines(failure)
20
+ failure.error ? failure.error.lines.map(&:chomp) : []
21
+ end
22
+
23
+ def example_line(example)
24
+ line = [example.id, example.description].reject(&:empty?).join(" ")
25
+ example.message.empty? ? line : "#{line} -- #{example.message}"
26
+ end
27
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../baseline"
4
+
5
+ # What a baseline run of one spec file sends back across the fork: whether it
6
+ # passed and, when it did not, enough to say why. A plain hash of strings and
7
+ # numbers, so it marshals over the pipe whatever the test framework is.
8
+ #
9
+ # The detail is bounded here, in the child: a spec file with a thousand failing
10
+ # examples has nothing more to say after the first few. Their ids are kept in
11
+ # far greater number, since a mutation run is compared against all of them.
12
+ module Evilution::Baseline::Report
13
+ MAX_EXAMPLES = 10
14
+ MAX_IDS = 1000
15
+ MAX_MESSAGE_LENGTH = 300
16
+ MESSAGE_LINES = 3
17
+ ERROR_LINES = 5
18
+ ELLIPSIS = "..."
19
+
20
+ # failed_ids: every failing example, named so a mutation run can recognise
21
+ # it again (see Integration::RSpec::ExampleIds); empty where the framework
22
+ # does not provide such names.
23
+ def self.build(passed:, failures: [], error: nil, failed_ids: [])
24
+ {
25
+ passed: passed ? true : false,
26
+ failure_count: failures.length,
27
+ failures: failures.first(MAX_EXAMPLES).map { |failure| example(failure) },
28
+ error: error_excerpt(error),
29
+ failed_ids: failed_ids.first(MAX_IDS)
30
+ }
31
+ end
32
+
33
+ # A runner may still answer with a bare pass/fail.
34
+ def self.from(outcome)
35
+ outcome.is_a?(Hash) ? outcome : build(passed: outcome)
36
+ end
37
+
38
+ def self.example(failure)
39
+ {
40
+ id: failure[:id].to_s,
41
+ description: failure[:description].to_s,
42
+ message: truncate(lines_of(failure[:message]).first(MESSAGE_LINES).join(" "))
43
+ }
44
+ end
45
+ private_class_method :example
46
+
47
+ # Each line is cut on its own, so one long path does not cost the lines
48
+ # after it.
49
+ def self.error_excerpt(error)
50
+ lines = lines_of(error).first(ERROR_LINES)
51
+ lines.empty? ? nil : lines.map { |line| truncate(line) }.join("\n")
52
+ end
53
+ private_class_method :error_excerpt
54
+
55
+ def self.lines_of(text)
56
+ text.to_s.lines.map(&:strip).reject(&:empty?)
57
+ end
58
+ private_class_method :lines_of
59
+
60
+ def self.truncate(text)
61
+ return text if text.length <= MAX_MESSAGE_LENGTH
62
+
63
+ "#{text[0, MAX_MESSAGE_LENGTH - ELLIPSIS.length]}#{ELLIPSIS}"
64
+ end
65
+ private_class_method :truncate
66
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../baseline"
4
+
5
+ class Evilution::Baseline
6
+ ExampleFailure = Data.define(:id, :description, :message)
7
+
8
+ # Why one spec file was red in the baseline: the examples that failed, or the
9
+ # error that stopped it before any example could -- a load error, a runner
10
+ # that raised, a timeout. example_count is every failing example; examples
11
+ # holds only the first few, for display; failed_ids names all of them the
12
+ # way a mutation run does, for comparison.
13
+ SpecFailure = Data.define(:spec_file, :examples, :example_count, :error, :failed_ids) do
14
+ def self.from_report(spec_file, report)
15
+ examples = Array(report[:failures]).map { |failure| ExampleFailure.new(**failure) }
16
+ new(
17
+ spec_file: spec_file,
18
+ examples: examples,
19
+ example_count: report[:failure_count] || examples.length,
20
+ error: report[:error],
21
+ failed_ids: Array(report[:failed_ids])
22
+ )
23
+ end
24
+
25
+ def initialize(spec_file:, examples: [], example_count: 0, error: nil, failed_ids: [])
26
+ super
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ spec_file: spec_file,
32
+ error: error,
33
+ failing_examples: example_count,
34
+ examples: examples.map(&:to_h)
35
+ }
36
+ end
37
+ end
38
+ end
@@ -2,10 +2,11 @@
2
2
 
3
3
  require_relative "spec_resolver"
4
4
  require_relative "process_cleanup"
5
+ require_relative "diagnostic"
5
6
 
6
7
  class Evilution::Baseline
7
- Result = Struct.new(:failed_spec_files, :duration) do
8
- def initialize(**)
8
+ Result = Struct.new(:failed_spec_files, :duration, :failures) do
9
+ def initialize(failed_spec_files:, duration:, failures: [])
9
10
  super
10
11
  freeze
11
12
  end
@@ -13,6 +14,11 @@ class Evilution::Baseline
13
14
  def failed?
14
15
  !failed_spec_files.empty?
15
16
  end
17
+
18
+ # Every example known to have failed before any mutation ran.
19
+ def failed_example_ids
20
+ failures.flat_map(&:failed_ids).to_set
21
+ end
16
22
  end
17
23
 
18
24
  # spec_selector: the object `run` resolves with (integration layout plus
@@ -37,18 +43,20 @@ class Evilution::Baseline
37
43
 
38
44
  def call(subjects)
39
45
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
- spec_files = baseline_spec_files(subjects)
41
- failed = Set.new
42
-
43
- spec_files.each do |spec_file|
44
- failed.add(spec_file) unless run_spec_file(spec_file)
45
- end
46
+ failures = baseline_spec_files(subjects).filter_map { |spec_file| check_spec_file(spec_file) }
46
47
 
47
48
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
48
- Result.new(failed_spec_files: failed, duration: duration)
49
+ Result.new(failed_spec_files: failures.to_set(&:spec_file), duration: duration, failures: failures)
49
50
  end
50
51
 
51
52
  def run_spec_file(spec_file)
53
+ check_spec_file(spec_file).nil?
54
+ end
55
+
56
+ # nil when the spec file passed, otherwise why it did not. Anything that
57
+ # stops the baseline from getting an answer counts as failing, and is
58
+ # recorded as the reason.
59
+ def check_spec_file(spec_file)
52
60
  raise Evilution::Error, "no baseline runner configured" unless @runner
53
61
 
54
62
  read_io, write_io = IO.pipe
@@ -57,11 +65,11 @@ class Evilution::Baseline
57
65
  read_result(read_io, pid, spec_file)
58
66
  rescue Evilution::Error
59
67
  raise
60
- rescue StandardError
61
- false
68
+ rescue StandardError => e
69
+ warned(SpecFailure.new(spec_file: spec_file, error: "#{e.class}: #{e.message}"))
62
70
  ensure
63
- read_io&.close
64
- write_io&.close
71
+ read_io.close if read_io
72
+ write_io.close if write_io
65
73
  end
66
74
 
67
75
  def fork_spec_runner(spec_file, read_io, write_io)
@@ -71,32 +79,28 @@ class Evilution::Baseline
71
79
  $stdout.reopen(File::NULL, "w")
72
80
  $stderr.reopen(File::NULL, "w")
73
81
 
74
- passed = runner.call(spec_file)
75
- Marshal.dump({ passed: passed }, write_io)
82
+ report = child_report(runner, spec_file)
83
+ Marshal.dump(report, write_io)
76
84
  write_io.close
77
- exit!(passed ? 0 : 1)
85
+ exit!(report[:passed] ? 0 : 1)
78
86
  end
79
87
  end
80
88
 
81
89
  GRACE_PERIOD = 0.5
82
90
 
83
91
  def read_result(read_io, pid, spec_file)
84
- if read_io.wait_readable(@timeout)
85
- data = read_io.read
86
- Process.wait(pid)
87
- return false if data.empty?
88
-
89
- result = Marshal.load(data)
90
- result[:passed]
91
- else
92
- terminate_child(pid)
93
- warn_timeout(spec_file)
94
- false
95
- end
92
+ return timed_out(pid, spec_file) unless read_io.wait_readable(@timeout)
93
+
94
+ data = read_io.read
95
+ _, status = Process.wait2(pid)
96
+ return warned(SpecFailure.new(spec_file: spec_file, error: unreported_error(status))) if data.empty?
97
+
98
+ report = Marshal.load(data)
99
+ report[:passed] ? nil : warned(SpecFailure.from_report(spec_file, report))
96
100
  end
97
101
 
98
102
  def warn_timeout(spec_file)
99
- warn "[evilution] Baseline for #{spec_file} timed out after #{@timeout}s; treating it as failing."
103
+ Evilution::Diagnostic.warn("[evilution] Baseline for #{spec_file} timed out after #{@timeout}s; treating it as failing.")
100
104
  end
101
105
 
102
106
  def terminate_child(pid)
@@ -114,6 +118,35 @@ class Evilution::Baseline
114
118
 
115
119
  private
116
120
 
121
+ # Runs in the child. A runner that raises -- a spec helper that cannot load,
122
+ # a framework that is not there -- is a failure with a reason, not a child
123
+ # that dies without a word.
124
+ def child_report(runner, spec_file)
125
+ Report.from(runner.call(spec_file))
126
+ rescue StandardError, ScriptError => e
127
+ Report.build(passed: false, error: "#{e.class}: #{e.message}")
128
+ end
129
+
130
+ def timed_out(pid, spec_file)
131
+ terminate_child(pid)
132
+ warn_timeout(spec_file)
133
+ SpecFailure.new(spec_file: spec_file, error: "timed out after #{@timeout}s")
134
+ end
135
+
136
+ def unreported_error(status)
137
+ cause = status.signaled? ? "signal #{status.termsig}" : "exit status #{status.exitstatus}"
138
+ "baseline process ended without reporting (#{cause})"
139
+ end
140
+
141
+ def warned(failure)
142
+ detail = FailureFormatter.new.call(failure).map { |line| " #{line}" }
143
+ Evilution::Diagnostic.warn(
144
+ ["[evilution] Baseline failed for #{failure.spec_file}; " \
145
+ "a mutation that fails only its already-failing examples will be reported neutral.", *detail].join("\n")
146
+ )
147
+ failure
148
+ end
149
+
117
150
  def baseline_spec_files(subjects)
118
151
  return Array(@test_files).uniq if @test_files && !@test_files.empty?
119
152
 
@@ -137,7 +170,7 @@ class Evilution::Baseline
137
170
 
138
171
  def warn_no_matching_test(file_path)
139
172
  action = @fallback_to_full_suite ? "running full suite" : "marking its mutations unresolved"
140
- warn "[evilution] No matching test found for #{file_path}, #{action}. #{no_match_hint(file_path)}"
173
+ Evilution::Diagnostic.warn("[evilution] No matching test found for #{file_path}, #{action}. #{no_match_hint(file_path)}")
141
174
  end
142
175
 
143
176
  def no_match_hint(file_path)
@@ -150,3 +183,7 @@ class Evilution::Baseline
150
183
  @fallback_to_full_suite ? "#{hint}." : "#{hint}, or --fallback-full-suite to run the whole suite."
151
184
  end
152
185
  end
186
+
187
+ require_relative "baseline/report"
188
+ require_relative "baseline/spec_failure"
189
+ require_relative "baseline/failure_formatter"