evilution 1.2.0 → 1.4.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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -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/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pattern"
4
+
5
+ # Reads a method name the way an attribute value spells it: an identifier with
6
+ # an optional `?`, `!` or `=`, a bare operator, or anything else in quotes.
7
+ class Evilution::AST::Pattern::MethodName
8
+ # Longest first, so `<=>` is not read as `<=`. Operators starting with `!`,
9
+ # `*` or `|` already mean negation, a wildcard or an alternative in a
10
+ # pattern, and have to be quoted.
11
+ OPERATORS = %w[<=> === == =~ []= [] <= << < >= >> > +@ -@ + - / % & ^ ~].freeze
12
+ IDENTIFIER = /\G[a-zA-Z_][a-zA-Z0-9_]*[?!=]?/
13
+ QUOTES = %w[' "].freeze
14
+
15
+ # The name starting at +pos+ in +input+, and the position just after it.
16
+ def self.scan(input, pos)
17
+ new(input, pos).scan
18
+ end
19
+
20
+ def initialize(input, pos)
21
+ @input = input
22
+ @pos = pos
23
+ end
24
+
25
+ def scan
26
+ char = @input[@pos]
27
+ raise Evilution::ConfigError, "unexpected end of pattern at position #{@pos}" if char.nil?
28
+
29
+ name = QUOTES.include?(char) ? quoted(char) : operator || identifier(char)
30
+ [name, @pos]
31
+ end
32
+
33
+ private
34
+
35
+ def quoted(quote)
36
+ finish = @input.index(quote, @pos + 1)
37
+ raise Evilution::ConfigError, "unterminated quoted name starting at position #{@pos}" if finish.nil?
38
+
39
+ name = @input[(@pos + 1)...finish]
40
+ raise Evilution::ConfigError, "empty quoted name at position #{@pos}" if name.empty?
41
+
42
+ @pos = finish + 1
43
+ name
44
+ end
45
+
46
+ def operator
47
+ name = OPERATORS.find { |candidate| @input[@pos, candidate.length] == candidate }
48
+ @pos += name.length if name
49
+ name
50
+ end
51
+
52
+ def identifier(char)
53
+ match = IDENTIFIER.match(@input, @pos)
54
+ if match.nil?
55
+ raise Evilution::ConfigError,
56
+ "invalid name starting with '#{char}' at position #{@pos}; " \
57
+ "quote names that are not identifiers or operators, e.g. name='|'"
58
+ end
59
+
60
+ @pos = match.end(0)
61
+ match[0]
62
+ end
63
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../pattern"
3
4
  require_relative "matcher"
5
+ require_relative "method_name"
4
6
 
5
7
  class Evilution::AST::Pattern::Parser
6
8
  def initialize(input)
@@ -38,17 +40,18 @@ class Evilution::AST::Pattern::Parser
38
40
  node_type = consume_identifier
39
41
  skip_whitespace
40
42
 
41
- attributes = {}
42
- if current_char == "{"
43
- advance(1)
44
- attributes = parse_attributes
45
- skip_whitespace
46
- expect_char("}")
47
- end
48
-
43
+ attributes = current_char == "{" ? parse_braced_attributes : {}
49
44
  Evilution::AST::Pattern::NodeMatcher.new(node_type, attributes)
50
45
  end
51
46
 
47
+ def parse_braced_attributes
48
+ advance(1)
49
+ attributes = parse_attributes
50
+ skip_whitespace
51
+ expect_char("}")
52
+ attributes
53
+ end
54
+
52
55
  def parse_attributes
53
56
  attrs = {}
54
57
  skip_whitespace
@@ -108,15 +111,11 @@ class Evilution::AST::Pattern::Parser
108
111
  end
109
112
 
110
113
  def parse_value_or_nested
111
- id = consume_identifier
114
+ id, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
112
115
  skip_whitespace
113
116
 
114
117
  if current_char == "{"
115
- advance(1)
116
- attrs = parse_attributes
117
- skip_whitespace
118
- expect_char("}")
119
- Evilution::AST::Pattern::NodeMatcher.new(id, attrs)
118
+ Evilution::AST::Pattern::NodeMatcher.new(id, parse_braced_attributes)
120
119
  else
121
120
  parse_alternatives_from(id)
122
121
  end
@@ -132,7 +131,8 @@ class Evilution::AST::Pattern::Parser
132
131
  advance(1)
133
132
  values << "*"
134
133
  else
135
- values << consume_identifier
134
+ name, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
135
+ values << name
136
136
  end
137
137
  end
138
138
 
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../ast"
4
+
3
5
  module Evilution::AST::Pattern
4
6
  end
@@ -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,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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "../ast"
4
5
 
5
6
  class Evilution::AST::SorbetSigDetector
6
7
  def call(source)
@@ -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 -- `scope` lambdas, callback macros, constant lists -- or in a
10
+ # concern's `included` block, which is a class body written elsewhere. Mutations are
11
+ # generated per subject, so a run aimed at these lines generates none of its
12
+ # own, and saying so is the only way a reader can tell "nothing to mutate"
13
+ # 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
+ included = Evilution::AST::IncludedBlock.of(node)
59
+ return statements(included.body) if included
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