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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +100 -0
- data/.rubocop_todo.yml +14 -0
- data/CHANGELOG.md +124 -0
- data/README.md +142 -28
- data/docs/architecture.md +123 -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 +120 -16
- data/lib/evilution/ast/pattern/filter.rb +1 -0
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +15 -15
- data/lib/evilution/ast/pattern.rb +2 -0
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +1 -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 +101 -42
- data/lib/evilution/child_output.rb +1 -1
- data/lib/evilution/cli/command.rb +1 -0
- data/lib/evilution/cli/commands/tests_list.rb +4 -3
- data/lib/evilution/cli/commands.rb +2 -0
- data/lib/evilution/cli/dispatcher.rb +2 -0
- data/lib/evilution/cli/exit_guard.rb +2 -0
- data/lib/evilution/cli/parsed_args.rb +2 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -0
- data/lib/evilution/cli/parser/file_args.rb +4 -1
- data/lib/evilution/cli/parser/options_builder.rb +4 -1
- data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
- data/lib/evilution/cli/parser.rb +1 -0
- data/lib/evilution/cli/printers/tests_list.rb +5 -4
- data/lib/evilution/cli/printers.rb +2 -0
- data/lib/evilution/cli/result.rb +2 -0
- data/lib/evilution/cli.rb +8 -2
- data/lib/evilution/config/builders.rb +2 -0
- data/lib/evilution/config/env_loader.rb +2 -0
- data/lib/evilution/config/file_loader.rb +1 -0
- data/lib/evilution/config/sources.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
- data/lib/evilution/config/validators/fail_fast.rb +1 -0
- data/lib/evilution/config/validators/hooks.rb +1 -0
- data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
- data/lib/evilution/config/validators/integration.rb +1 -0
- data/lib/evilution/config/validators/isolation.rb +1 -0
- data/lib/evilution/config/validators/jobs.rb +1 -0
- data/lib/evilution/config/validators/preload.rb +1 -0
- data/lib/evilution/config/validators/profile.rb +1 -0
- data/lib/evilution/config/validators/spec_mappings.rb +3 -1
- data/lib/evilution/config/validators/spec_pattern.rb +1 -0
- data/lib/evilution/config/validators/warmup.rb +17 -0
- data/lib/evilution/config/validators.rb +2 -0
- data/lib/evilution/config.rb +7 -5
- data/lib/evilution/coverage.rb +1 -1
- data/lib/evilution/coverage_example_filter.rb +1 -1
- data/lib/evilution/diagnostic.rb +1 -1
- 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 +46 -1
- 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 +139 -12
- 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/loading/test_load_path.rb +21 -13
- 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/configuration_state.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
- data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
- data/lib/evilution/integration/rspec/state_guard.rb +10 -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/actions/environment.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
- data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
- 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 +8 -3
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
- data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
- data/lib/evilution/mutator/operator/bang_method.rb +11 -3
- data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
- data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
- data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
- data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
- data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
- data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
- data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
- data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
- data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -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/inequality_to_negated_identity.rb +43 -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_argument_removal.rb +47 -0
- 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/proc_to_lambda.rb +43 -0
- data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -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_anchor_to_predicate.rb +136 -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/safe_navigation_removal.rb +67 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
- data/lib/evilution/mutator/primitives.rb +25 -0
- data/lib/evilution/mutator/registry.rb +54 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/parallel/pool.rb +1 -0
- data/lib/evilution/parallel_db_warning.rb +1 -1
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/rails_warmup.rb +61 -0
- 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/html/report.rb +1 -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/coverage_gap_grouper.rb +1 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/subject_scorer.rb +1 -0
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +26 -11
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/isolation_resolver.rb +23 -2
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
- 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/source_ast_cache.rb +1 -1
- data/lib/evilution/spec_ast_cache.rb +1 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +51 -0
- data/script/memory_check +62 -16
- 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 =
|
|
114
|
+
id, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
|
|
112
115
|
skip_whitespace
|
|
113
116
|
|
|
114
117
|
if current_char == "{"
|
|
115
|
-
|
|
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
|
-
|
|
134
|
+
name, @pos = Evilution::AST::Pattern::MethodName.scan(@input, @pos)
|
|
135
|
+
values << name
|
|
136
136
|
end
|
|
137
137
|
end
|
|
138
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
|
|
@@ -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
|