evilution 1.3.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -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 +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
@@ -2,6 +2,11 @@
2
2
 
3
3
  require "prism"
4
4
  require_relative "../ast"
5
+ require_relative "value_object_definition"
6
+ require_relative "scope_declaration"
7
+ require_relative "aasm_declaration"
8
+ require_relative "included_block"
9
+ require_relative "callback_declaration"
5
10
 
6
11
  module Evilution::AST
7
12
  class Parser
@@ -40,55 +45,153 @@ module Evilution::AST
40
45
  @file_path = file_path
41
46
  @subjects = []
42
47
  @context = []
48
+ @singleton = [false]
43
49
  end
44
50
 
45
51
  def visit_module_node(node)
46
52
  @context.push(constant_name(node.constant_path))
47
- super
53
+ IncludedBlock.in_body(node.body).each do |block|
54
+ add_scope_subjects(block.body)
55
+ add_aasm_subjects(block.body)
56
+ add_callback_subjects(block.body)
57
+ end
58
+ within_scope(singleton: false) { super }
48
59
  @context.pop
49
60
  end
50
61
 
62
+ # A class whose superclass is a value-object definition
63
+ # (`class Coord < Data.define(:lat, :lng)`) makes a constant subject of
64
+ # that definition, named after the class.
51
65
  def visit_class_node(node)
52
66
  @context.push(constant_name(node.constant_path))
53
- super
67
+ superclass = node.superclass
68
+ add_subject(superclass, @context.join("::"), :constant) if ValueObjectDefinition.match?(superclass)
69
+ add_scope_subjects(node.body)
70
+ add_aasm_subjects(node.body)
71
+ add_callback_subjects(node.body)
72
+ within_scope(singleton: false) { super }
54
73
  @context.pop
55
74
  end
56
75
 
76
+ # A `def` inside `class << self` defines a method on the enclosing
77
+ # class's singleton, like `def self.name`; inside `class << Store` it
78
+ # defines one on Store.
79
+ def visit_singleton_class_node(node)
80
+ constant = constant_expression?(node.expression)
81
+ @context.push(constant_name(node.expression)) if constant
82
+ within_scope(singleton: true) { super }
83
+ @context.pop if constant
84
+ end
85
+
57
86
  def visit_def_node(node)
58
- separator = node.receiver ? "." : "#"
59
- add_subject(node, separator)
87
+ separator = node.receiver || @singleton.last ? "." : "#"
88
+ add_subject(node, "#{@context.join("::")}#{separator}#{node.name}", :method)
60
89
  super
61
90
  end
62
91
 
92
+ def visit_constant_write_node(node)
93
+ return super unless ValueObjectDefinition.match?(node.value)
94
+
95
+ within_definition(node.value, node.name.to_s) { super }
96
+ end
97
+
98
+ def visit_constant_path_write_node(node)
99
+ return super unless ValueObjectDefinition.match?(node.value)
100
+
101
+ within_definition(node.value, path_name(node.target)) { super }
102
+ end
103
+
63
104
  private
64
105
 
65
- def add_subject(node, separator)
66
- scope = @context.join("::")
67
- name = if scope.empty?
68
- "#{separator}#{node.name}"
69
- else
70
- "#{scope}#{separator}#{node.name}"
71
- end
106
+ def within_scope(singleton:)
107
+ @singleton.push(singleton)
108
+ yield
109
+ ensure
110
+ @singleton.pop
111
+ end
112
+
113
+ def constant_expression?(node)
114
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
115
+ end
116
+
117
+ # A value-object definition assigned to a constant is a subject of its
118
+ # own, and a scope for what its block defines: `def area` in
119
+ # `Size = Struct.new(:w, :h) do ... end` is `Size#area`.
120
+ def within_definition(definition, name)
121
+ @context.push(name)
122
+ add_subject(definition, @context.join("::"), :constant)
123
+ yield
124
+ @context.pop
125
+ end
126
+
127
+ # `scope :recent, -> { ... }` defines the class method `recent`. The
128
+ # subject spans the whole declaration and mutates its body.
129
+ # Written in a concern's `included` block it defines that method on every
130
+ # class including the concern, and is named after the concern.
131
+ def add_scope_subjects(body)
132
+ ScopeDeclaration.in_body(body).each do |declaration|
133
+ name = "#{@context.join("::")}.#{ScopeDeclaration.scope_name(declaration)}"
134
+ add_subject(ScopeDeclaration.body_of(declaration), name, :scope, span: declaration)
135
+ end
136
+ end
137
+
138
+ # A guard or callback written out inside `aasm do ... end` runs when the
139
+ # method its event or state defines is called (`ship`, `paid?`), and is
140
+ # named after it. The subject spans that declaration and mutates the
141
+ # callable's body; one event may hold several. A machine declared in a
142
+ # concern's `included` block is named after the concern.
143
+ def add_aasm_subjects(body)
144
+ AasmDeclaration.in_body(body).each do |callable|
145
+ name = "#{@context.join("::")}##{callable.method_name}"
146
+ add_subject(callable.body, name, :aasm, span: callable.declaration)
147
+ end
148
+ end
72
149
 
73
- loc = node.location
74
- method_source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
75
- .force_encoding(@source.encoding)
150
+ # A condition or a callback written out in a declaration such as
151
+ # `validate :credit_limit, if: -> { ... }` defines no method to be named
152
+ # after, so it is named the way the declaration reads:
153
+ # `Order.validate(:credit_limit)`. The subject spans the declaration and
154
+ # mutates the callable's body. In a concern's `included` block it is named
155
+ # after the concern.
156
+ def add_callback_subjects(body)
157
+ CallbackDeclaration.in_body(body).each do |callable|
158
+ add_subject(callable.body, "#{@context.join("::")}.#{callable.label}", :callback, span: callable.declaration)
159
+ end
160
+ end
161
+
162
+ def add_subject(node, name, kind, span: node)
163
+ loc = span.location
164
+ source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
165
+ .force_encoding(@source.encoding)
76
166
 
77
167
  @subjects << Evilution::Subject.new(
78
168
  name: name,
79
169
  file_path: @file_path,
80
170
  line_number: loc.start_line,
81
- source: method_source,
82
- node: node
171
+ source: source,
172
+ node: node,
173
+ kind: kind
83
174
  )
84
175
  end
85
176
 
177
+ # `self::Unit` has no static full name; it is named within the scope it
178
+ # is written in, like a plain `Unit`.
179
+ def path_name(target)
180
+ target.full_name.delete_prefix("::")
181
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
182
+ target.name.to_s
183
+ end
184
+
185
+ # `class self::Unit` and `class << self::Unit` have no static full name
186
+ # either; like `path_name`, they are named by their own constant.
86
187
  def constant_name(node)
87
188
  if node.respond_to?(:full_name)
88
189
  node.full_name
89
190
  else
90
191
  node.name.to_s
91
192
  end
193
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
194
+ node.name.to_s
92
195
  end
93
196
  end
94
197
  end
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../pattern"
4
4
  require_relative "matcher"
5
+ require_relative "method_name"
5
6
 
6
7
  class Evilution::AST::Pattern::Parser
7
8
  def initialize(input)
@@ -39,17 +40,18 @@ class Evilution::AST::Pattern::Parser
39
40
  node_type = consume_identifier
40
41
  skip_whitespace
41
42
 
42
- attributes = {}
43
- if current_char == "{"
44
- advance(1)
45
- attributes = parse_attributes
46
- skip_whitespace
47
- expect_char("}")
48
- end
49
-
43
+ attributes = current_char == "{" ? parse_braced_attributes : {}
50
44
  Evilution::AST::Pattern::NodeMatcher.new(node_type, attributes)
51
45
  end
52
46
 
47
+ def parse_braced_attributes
48
+ advance(1)
49
+ attributes = parse_attributes
50
+ skip_whitespace
51
+ expect_char("}")
52
+ attributes
53
+ end
54
+
53
55
  def parse_attributes
54
56
  attrs = {}
55
57
  skip_whitespace
@@ -109,15 +111,11 @@ class Evilution::AST::Pattern::Parser
109
111
  end
110
112
 
111
113
  def parse_value_or_nested
112
- id = consume_identifier
114
+ id, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
113
115
  skip_whitespace
114
116
 
115
117
  if current_char == "{"
116
- advance(1)
117
- attrs = parse_attributes
118
- skip_whitespace
119
- expect_char("}")
120
- Evilution::AST::Pattern::NodeMatcher.new(id, attrs)
118
+ Evilution::AST::Pattern::NodeMatcher.new(id, parse_braced_attributes)
121
119
  else
122
120
  parse_alternatives_from(id)
123
121
  end
@@ -133,7 +131,8 @@ class Evilution::AST::Pattern::Parser
133
131
  advance(1)
134
132
  values << "*"
135
133
  else
136
- values << consume_identifier
134
+ name, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
135
+ values << name
137
136
  end
138
137
  end
139
138
 
@@ -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
@@ -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