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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- metadata +83 -4
data/lib/evilution/ast/parser.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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:
|
|
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 =
|
|
114
|
+
id, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
|
|
113
115
|
skip_whitespace
|
|
114
116
|
|
|
115
117
|
if current_char == "{"
|
|
116
|
-
|
|
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
|
-
|
|
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
|