henitai 0.5.1 → 0.6.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/CHANGELOG.md +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -12,10 +12,18 @@ module Henitai
|
|
|
12
12
|
class SourceParser
|
|
13
13
|
DEFAULT_PATH = "(string)"
|
|
14
14
|
|
|
15
|
+
# Emits the modern parser-gem AST format (procarg0, kwargs, index, lambda,
|
|
16
|
+
# forward_arg, ...). The legacy format cannot tell `|x|` from `|x,|` or
|
|
17
|
+
# keyword arguments from a braced hash, so re-rendering a mutated subtree
|
|
18
|
+
# through the unparser would silently change its meaning.
|
|
19
|
+
class Builder < Prism::Translation::Parser::Builder
|
|
20
|
+
modernize
|
|
21
|
+
end
|
|
22
|
+
|
|
15
23
|
@cache = {}
|
|
16
24
|
|
|
17
|
-
def self.parse(source, path: DEFAULT_PATH)
|
|
18
|
-
new.parse(source, path:)
|
|
25
|
+
def self.parse(source, path: DEFAULT_PATH, line: 1)
|
|
26
|
+
new.parse(source, path:, line:)
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
# Returns the parsed AST for +path+, re-using a cached result when the
|
|
@@ -31,20 +39,32 @@ module Henitai
|
|
|
31
39
|
@cache.clear
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
# Reads +path+ as UTF-8 source, honouring a magic encoding comment the
|
|
43
|
+
# same way parsing does. Raises EncodingError when the bytes are invalid
|
|
44
|
+
# in the declared (or default UTF-8) encoding.
|
|
45
|
+
def self.read_file(path)
|
|
46
|
+
buffer = Parser::Source::Buffer.new(path)
|
|
47
|
+
buffer.source = File.read(path, encoding: Encoding::UTF_8)
|
|
48
|
+
buffer.source
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# +line+ is the number of the source's first line, as for Kernel#eval.
|
|
52
|
+
def parse(source, path: DEFAULT_PATH, line: 1)
|
|
53
|
+
Prism::Translation::ParserCurrent.new(Builder.new).parse(source_buffer(source, path, line))
|
|
36
54
|
end
|
|
37
55
|
|
|
56
|
+
# Read as UTF-8, Ruby's own default source encoding, not the process
|
|
57
|
+
# locale: with LANG=C the default external encoding is US-ASCII and every
|
|
58
|
+
# non-ASCII source file would fail to parse. A magic encoding comment is
|
|
59
|
+
# still honoured by the parser buffer, which re-encodes to UTF-8.
|
|
38
60
|
def parse_file(path)
|
|
39
|
-
|
|
40
|
-
# encoding comments can be handled by a future encoding-aware option.
|
|
41
|
-
parse(File.read(path), path:)
|
|
61
|
+
parse(File.read(path, encoding: Encoding::UTF_8), path:)
|
|
42
62
|
end
|
|
43
63
|
|
|
44
64
|
private
|
|
45
65
|
|
|
46
|
-
def source_buffer(source, path)
|
|
47
|
-
Parser::Source::Buffer.new(path).tap do |buffer|
|
|
66
|
+
def source_buffer(source, path, line = 1)
|
|
67
|
+
Parser::Source::Buffer.new(path, line).tap do |buffer|
|
|
48
68
|
buffer.source = source
|
|
49
69
|
end
|
|
50
70
|
end
|
|
@@ -10,9 +10,11 @@ module Henitai
|
|
|
10
10
|
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
|
|
11
11
|
|
|
12
12
|
def initialize(coverage_report_reader: CoverageReportReader.new,
|
|
13
|
-
skip_directives: MutationSkipDirectives.new
|
|
13
|
+
skip_directives: MutationSkipDirectives.new,
|
|
14
|
+
fidelity_check: FidelityCheck.new)
|
|
14
15
|
@coverage_report_reader = coverage_report_reader
|
|
15
16
|
@skip_directives = skip_directives
|
|
17
|
+
@fidelity_check = fidelity_check
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# This method is the gate-level filter orchestrator.
|
|
@@ -22,6 +24,7 @@ module Henitai
|
|
|
22
24
|
|
|
23
25
|
Array(mutants).each do |mutant|
|
|
24
26
|
next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
|
|
27
|
+
next if unfaithful_mutant?(mutant)
|
|
25
28
|
|
|
26
29
|
mark_equivalent_mutant(mutant)
|
|
27
30
|
mark_no_coverage_mutant(
|
|
@@ -64,21 +67,22 @@ module Henitai
|
|
|
64
67
|
|
|
65
68
|
private
|
|
66
69
|
|
|
67
|
-
attr_reader :coverage_report_reader, :skip_directives
|
|
70
|
+
attr_reader :coverage_report_reader, :skip_directives, :fidelity_check
|
|
68
71
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
return
|
|
72
|
+
def ignore_pattern_for(mutant, config)
|
|
73
|
+
patterns = compiled_ignore_patterns(config)
|
|
74
|
+
return if patterns.empty?
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
+
line = source_line_for(mutant)
|
|
77
|
+
patterns.find { |pattern| pattern.match?(line) }
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
def ignored_mutant?(mutant, config)
|
|
79
|
-
|
|
81
|
+
pattern = ignore_pattern_for(mutant, config)
|
|
82
|
+
return false unless pattern
|
|
80
83
|
|
|
81
84
|
mutant.status = :ignored
|
|
85
|
+
mutant.ignore_reason = "ignore_patterns: #{pattern.source}" if mutant.respond_to?(:ignore_reason=)
|
|
82
86
|
true
|
|
83
87
|
end
|
|
84
88
|
|
|
@@ -91,6 +95,17 @@ module Henitai
|
|
|
91
95
|
true
|
|
92
96
|
end
|
|
93
97
|
|
|
98
|
+
# A mutant whose activated code is not the reported mutation must not
|
|
99
|
+
# run: any test failure would be credited to a change it never made.
|
|
100
|
+
def unfaithful_mutant?(mutant)
|
|
101
|
+
reason = fidelity_check.violation(mutant)
|
|
102
|
+
return false unless reason
|
|
103
|
+
|
|
104
|
+
mutant.status = :compile_error
|
|
105
|
+
mutant.status_reason = reason
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
94
109
|
def mark_equivalent_mutant(mutant)
|
|
95
110
|
return unless mutant.pending?
|
|
96
111
|
|
|
@@ -111,11 +126,27 @@ module Henitai
|
|
|
111
126
|
covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
|
|
112
127
|
end
|
|
113
128
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
# Ignore patterns are line-scoped: they match the source line on which the
|
|
130
|
+
# mutant's original node starts, like a trailing `# henitai:disable`.
|
|
131
|
+
def source_line_for(mutant)
|
|
132
|
+
line_number = mutant.location[:start_line]
|
|
133
|
+
return unless line_number.is_a?(Integer) && line_number.positive?
|
|
134
|
+
|
|
135
|
+
lines = source_lines_for(mutant.location[:file])
|
|
136
|
+
lines&.fetch(line_number - 1, nil)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def source_lines_for(path)
|
|
140
|
+
@source_lines ||= {}
|
|
141
|
+
return @source_lines[path] if @source_lines.key?(path)
|
|
142
|
+
|
|
143
|
+
@source_lines[path] = read_source_lines(path)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def read_source_lines(path)
|
|
147
|
+
SourceParser.read_file(path).lines
|
|
148
|
+
rescue SystemCallError, IOError, EncodingError
|
|
149
|
+
nil
|
|
119
150
|
end
|
|
120
151
|
|
|
121
152
|
def compiled_ignore_patterns(config)
|
|
@@ -1,11 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
3
4
|
require "unparser"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
# Suppresses mutants that do not produce syntactically valid Ruby.
|
|
8
|
+
#
|
|
9
|
+
# A mutant of a resolved method is checked in context: its activation
|
|
10
|
+
# source -- the whole method with the mutation spliced in -- must parse. A
|
|
11
|
+
# fragment such as `size: 2` (one keyword argument left) or a `next` guard
|
|
12
|
+
# is only valid inside its call or block, so checking it on its own would
|
|
13
|
+
# drop valid mutants. Without a method to splice into, the rendered
|
|
14
|
+
# fragment is compiled inside a bare method instead.
|
|
15
|
+
#
|
|
16
|
+
# A mutant that removes the only assignment of a local which is read later
|
|
17
|
+
# is suppressed as well (ACT-10): Ruby then parses the later read as a
|
|
18
|
+
# method call, so the mutant would fail with a NameError wherever it runs
|
|
19
|
+
# -- a trivial kill of a program other than the reported one.
|
|
7
20
|
class StillbornFilter
|
|
21
|
+
# Collects the names of identifiers Ruby parses as method calls.
|
|
22
|
+
class VariableCalls < Prism::Visitor
|
|
23
|
+
def self.tally(node)
|
|
24
|
+
new.tap { |visitor| node.accept(visitor) }.names.tally
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :names
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
super
|
|
31
|
+
@names = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def visit_call_node(node)
|
|
35
|
+
@names << node.name if node.variable_call?
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@original_calls = {}.compare_by_identity
|
|
42
|
+
end
|
|
43
|
+
|
|
8
44
|
def suppressed?(mutant)
|
|
45
|
+
activation = in_context_activation(mutant)
|
|
46
|
+
return fragment_invalid?(mutant) unless activation
|
|
47
|
+
|
|
48
|
+
result = Prism.parse(activation.source)
|
|
49
|
+
result.errors.any? || drops_local?(mutant.subject.ast_node, result.value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def drops_local?(method_node, activated)
|
|
55
|
+
original = @original_calls[method_node] ||= original_calls(method_node)
|
|
56
|
+
VariableCalls.tally(activated).any? do |name, count|
|
|
57
|
+
count > original.fetch(name, 0) && local_read?(method_node, name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def original_calls(method_node)
|
|
62
|
+
VariableCalls.tally(Prism.parse(method_node.location.expression.source).value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def local_read?(node, name)
|
|
66
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
67
|
+
return true if node.type == :lvar && node.children.first == name
|
|
68
|
+
|
|
69
|
+
node.children.any? { |child| local_read?(child, name) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def in_context_activation(mutant)
|
|
73
|
+
return unless mutant.subject.respond_to?(:ast_node) && mutant.subject.ast_node
|
|
74
|
+
|
|
75
|
+
Mutant::Activator.activation_for(mutant)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fragment_invalid?(mutant)
|
|
9
79
|
source = render(mutant)
|
|
10
80
|
return true unless source
|
|
11
81
|
|
|
@@ -15,8 +85,6 @@ module Henitai
|
|
|
15
85
|
true
|
|
16
86
|
end
|
|
17
87
|
|
|
18
|
-
private
|
|
19
|
-
|
|
20
88
|
def render(mutant)
|
|
21
89
|
Unparser.unparse(mutant.mutated_node)
|
|
22
90
|
rescue StandardError
|
data/lib/henitai/subject.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Henitai
|
|
|
14
14
|
# descriptions in the test suite.
|
|
15
15
|
class Subject
|
|
16
16
|
attr_reader :namespace, :method_name, :method_type, :source_file,
|
|
17
|
-
:source_range, :ast_node
|
|
17
|
+
:source_range, :ast_node, :lexical_scope
|
|
18
18
|
|
|
19
19
|
# @param expression [String] subject expression, e.g. "Foo::Bar#method"
|
|
20
20
|
def self.parse(expression)
|
|
@@ -25,11 +25,15 @@ module Henitai
|
|
|
25
25
|
# @param method_name [String] method name (nil for wildcard subjects)
|
|
26
26
|
# @param method_type [Symbol] :instance or :class
|
|
27
27
|
# @param source_location [Hash] file/range metadata for the subject source
|
|
28
|
+
# @param lexical_scope [Array<String>, nil] the openers of the modules and
|
|
29
|
+
# classes around the method as written ("module Outer", "class Inner",
|
|
30
|
+
# "class << self", "Point.class_eval do"); nil when a scope cannot be
|
|
31
|
+
# reopened by name
|
|
28
32
|
# The explicit keyword API matches the public call sites and keeps the
|
|
29
33
|
# metadata fields discoverable without a single options hash.
|
|
30
34
|
# rubocop:disable Metrics/ParameterLists
|
|
31
35
|
def initialize(expression: nil, namespace: nil, method_name: nil,
|
|
32
|
-
method_type: :instance, source_location: nil, ast_node: nil)
|
|
36
|
+
method_type: :instance, source_location: nil, ast_node: nil, lexical_scope: nil)
|
|
33
37
|
if expression
|
|
34
38
|
parse_expression(expression)
|
|
35
39
|
else
|
|
@@ -40,6 +44,7 @@ module Henitai
|
|
|
40
44
|
@source_file = source_location&.fetch(:file, nil)
|
|
41
45
|
@source_range = source_location&.fetch(:range, nil)
|
|
42
46
|
@ast_node = ast_node
|
|
47
|
+
@lexical_scope = lexical_scope
|
|
43
48
|
end
|
|
44
49
|
# rubocop:enable Metrics/ParameterLists
|
|
45
50
|
|
|
@@ -56,7 +61,7 @@ module Henitai
|
|
|
56
61
|
private
|
|
57
62
|
|
|
58
63
|
def parse_expression(expr)
|
|
59
|
-
if (m = expr.match(/\A(
|
|
64
|
+
if (m = expr.match(/\A([\w:]+)([#.])([^#.\s]+)\z/))
|
|
60
65
|
@namespace = m[1]
|
|
61
66
|
@method_type = m[2] == "." ? :class : :instance
|
|
62
67
|
@method_name = m[3]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "source_parser"
|
|
4
4
|
require_relative "subject"
|
|
5
|
+
require_relative "constant_path"
|
|
5
6
|
|
|
6
7
|
module Henitai
|
|
7
8
|
# Resolves AST subjects from Ruby source files.
|
|
@@ -26,6 +27,7 @@ module Henitai
|
|
|
26
27
|
private
|
|
27
28
|
|
|
28
29
|
def resolve_from_file(path)
|
|
30
|
+
@scope = []
|
|
29
31
|
subjects = []
|
|
30
32
|
walk(
|
|
31
33
|
SourceParser.parse_file(path),
|
|
@@ -38,6 +40,7 @@ module Henitai
|
|
|
38
40
|
|
|
39
41
|
def walk(node, namespace:, singleton_context:, subjects:)
|
|
40
42
|
return unless node.respond_to?(:type)
|
|
43
|
+
return walk_constructor_body(node, namespace, subjects) if constructor_assignment?(node)
|
|
41
44
|
return if anonymous_class_block?(node)
|
|
42
45
|
|
|
43
46
|
namespace, singleton_context = update_context(
|
|
@@ -46,8 +49,60 @@ module Henitai
|
|
|
46
49
|
singleton_context
|
|
47
50
|
)
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
within_scope(node) do
|
|
53
|
+
collect_subject(node, namespace, singleton_context, subjects)
|
|
54
|
+
traverse_children(node, namespace, singleton_context, subjects)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# +Point = Struct.new(:x) do ... end+: the block is evaluated with
|
|
59
|
+
# class_eval on the new class, so its methods belong to +Point+ while
|
|
60
|
+
# constant lookup stays in the enclosing scope. +Point.class_eval do+
|
|
61
|
+
# reproduces exactly that when the method is re-evaluated.
|
|
62
|
+
def walk_constructor_body(node, namespace, subjects)
|
|
63
|
+
@scope.push("#{assigned_constant_source(node)}.class_eval do")
|
|
64
|
+
walk(node.children[2].children[2], namespace: ConstantPath.qualify(namespace, ConstantPath.of(node)),
|
|
65
|
+
singleton_context: false, subjects:)
|
|
66
|
+
ensure
|
|
67
|
+
@scope.pop
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def constructor_assignment?(node)
|
|
71
|
+
node.type == :casgn && anonymous_class_block?(node.children[2])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def assigned_constant_source(node)
|
|
75
|
+
node.location.expression.begin.join(node.location.name).source
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Tracks the openers of the modules and classes around the walk position,
|
|
79
|
+
# so a method can later be re-evaluated in its original lexical scope.
|
|
80
|
+
def within_scope(node)
|
|
81
|
+
opener = scope_opener(node)
|
|
82
|
+
@scope.push(opener) unless opener == :none
|
|
83
|
+
yield
|
|
84
|
+
ensure
|
|
85
|
+
@scope.pop unless opener == :none
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scope_opener(node)
|
|
89
|
+
case node.type
|
|
90
|
+
when :class, :module then "#{node.type} #{node.children.first.location.expression.source}"
|
|
91
|
+
when :sclass then singleton_opener(node.children.first)
|
|
92
|
+
else :none
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def singleton_opener(receiver)
|
|
97
|
+
case receiver.type
|
|
98
|
+
when :self then "class << self"
|
|
99
|
+
when :const then "class << #{receiver.location.expression.source}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# nil when some enclosing scope cannot be reopened by name (class << obj).
|
|
104
|
+
def lexical_scope
|
|
105
|
+
@scope.include?(nil) ? nil : @scope.dup
|
|
51
106
|
end
|
|
52
107
|
|
|
53
108
|
def collect_subject(node, namespace, singleton_context, subjects)
|
|
@@ -70,7 +125,7 @@ module Henitai
|
|
|
70
125
|
|
|
71
126
|
receiver = call_node.children.first
|
|
72
127
|
method_name = call_node.children[1]
|
|
73
|
-
receiver_name =
|
|
128
|
+
receiver_name = ConstantPath.of(receiver)&.delete_prefix("::")
|
|
74
129
|
|
|
75
130
|
anonymous_constructor_call?(receiver_name, method_name)
|
|
76
131
|
end
|
|
@@ -86,10 +141,10 @@ module Henitai
|
|
|
86
141
|
def update_context(node, namespace, singleton_context)
|
|
87
142
|
case node.type
|
|
88
143
|
when :class, :module
|
|
89
|
-
[
|
|
144
|
+
[ConstantPath.qualify(namespace, ConstantPath.of(node.children.first)),
|
|
90
145
|
singleton_context]
|
|
91
146
|
when :sclass
|
|
92
|
-
[namespace, true]
|
|
147
|
+
[ConstantPath.owner(node.children.first, namespace), true]
|
|
93
148
|
else
|
|
94
149
|
[namespace, singleton_context]
|
|
95
150
|
end
|
|
@@ -102,7 +157,7 @@ module Henitai
|
|
|
102
157
|
when :def
|
|
103
158
|
instance_subject(node, namespace, singleton_context)
|
|
104
159
|
when :defs
|
|
105
|
-
class_subject(node, namespace)
|
|
160
|
+
class_subject(node, ConstantPath.owner(node.children.first, namespace))
|
|
106
161
|
end
|
|
107
162
|
end
|
|
108
163
|
|
|
@@ -114,7 +169,8 @@ module Henitai
|
|
|
114
169
|
method_name: method_name(node.children.first),
|
|
115
170
|
method_type: singleton_context ? :class : :instance,
|
|
116
171
|
source_location: source_location_for(node),
|
|
117
|
-
ast_node: node
|
|
172
|
+
ast_node: node,
|
|
173
|
+
lexical_scope:
|
|
118
174
|
)
|
|
119
175
|
end
|
|
120
176
|
|
|
@@ -126,7 +182,8 @@ module Henitai
|
|
|
126
182
|
method_name: method_name(node.children[1]),
|
|
127
183
|
method_type: :class,
|
|
128
184
|
source_location: source_location_for(node),
|
|
129
|
-
ast_node: node
|
|
185
|
+
ast_node: node,
|
|
186
|
+
lexical_scope:
|
|
130
187
|
)
|
|
131
188
|
end
|
|
132
189
|
|
|
@@ -147,29 +204,6 @@ module Henitai
|
|
|
147
204
|
)
|
|
148
205
|
end
|
|
149
206
|
|
|
150
|
-
def qualify_namespace(namespace, name)
|
|
151
|
-
return name if namespace.nil? || namespace.empty?
|
|
152
|
-
return namespace if name.nil? || name.empty?
|
|
153
|
-
|
|
154
|
-
"#{namespace}::#{name}"
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def constant_name(node)
|
|
158
|
-
return unless node.respond_to?(:type)
|
|
159
|
-
|
|
160
|
-
case node.type
|
|
161
|
-
when :const
|
|
162
|
-
parent_name = constant_name(node.children.first)
|
|
163
|
-
current_name = symbol_name(node.children.last)
|
|
164
|
-
|
|
165
|
-
return current_name if parent_name.nil? || parent_name.empty?
|
|
166
|
-
|
|
167
|
-
"#{parent_name}::#{current_name}"
|
|
168
|
-
when :cbase
|
|
169
|
-
""
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
207
|
def method_name(value)
|
|
174
208
|
symbol_name(value)
|
|
175
209
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Measures how long a baseline suite process spent before its first test
|
|
5
|
+
# started. The parent passes its spawn wall-clock time through
|
|
6
|
+
# STARTED_AT_ENV; the boot time covers loading helpers and spec files and
|
|
7
|
+
# running before(:suite).
|
|
8
|
+
class SuiteBootClock
|
|
9
|
+
STARTED_AT_ENV = "HENITAI_SUITE_STARTED_AT"
|
|
10
|
+
|
|
11
|
+
# @param started_at [Float, nil] epoch seconds of the suite spawn
|
|
12
|
+
# @param clock [#call] returns the current epoch seconds
|
|
13
|
+
def initialize(started_at:, clock: -> { Time.now.to_f })
|
|
14
|
+
@started_at = started_at
|
|
15
|
+
@clock = clock
|
|
16
|
+
@boot_seconds = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param env [Hash] environment of the suite process
|
|
20
|
+
# @return [SuiteBootClock]
|
|
21
|
+
def self.from_env(env, clock: -> { Time.now.to_f })
|
|
22
|
+
new(started_at: parse_timestamp(env[STARTED_AT_ENV]), clock:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.parse_timestamp(value)
|
|
26
|
+
Float(value)
|
|
27
|
+
rescue ArgumentError, TypeError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Records boot time at the first test start only; later calls are ignored.
|
|
32
|
+
def mark_first_test_started
|
|
33
|
+
return if @boot_seconds || @started_at.nil?
|
|
34
|
+
|
|
35
|
+
@boot_seconds = [@clock.call - @started_at, 0.0].max
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Float] boot seconds, 0.0 when unknown
|
|
39
|
+
def boot_seconds
|
|
40
|
+
@boot_seconds || 0.0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] true once a boot time has been recorded
|
|
44
|
+
def measured?
|
|
45
|
+
!@boot_seconds.nil?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Henitai
|
|
7
|
-
# Stores and retrieves pre-computed
|
|
7
|
+
# Stores and retrieves pre-computed activations (see Mutant::Activation) for
|
|
8
8
|
# survived mutants, enabling survivor reruns to skip the full mutant-generation
|
|
9
9
|
# pipeline.
|
|
10
10
|
#
|
|
@@ -14,7 +14,7 @@ module Henitai
|
|
|
14
14
|
# objects directly and execute them without re-parsing source files.
|
|
15
15
|
#
|
|
16
16
|
# A recipe entry encodes everything needed to activate and re-report a mutant:
|
|
17
|
-
# the
|
|
17
|
+
# the activation (source, mode, file, line), subject coordinates, operator, description,
|
|
18
18
|
# location, and the coveredBy test list.
|
|
19
19
|
class SurvivorActivationCache
|
|
20
20
|
FILENAME = "activation-recipes.json"
|
|
@@ -26,10 +26,10 @@ module Henitai
|
|
|
26
26
|
# @return [Hash<String, Hash>] stableId → recipe
|
|
27
27
|
def self.compute(survived_mutants)
|
|
28
28
|
survived_mutants.each_with_object({}) do |mutant, cache|
|
|
29
|
-
|
|
30
|
-
next unless
|
|
29
|
+
activation = Mutant::Activator.activation_for(mutant)
|
|
30
|
+
next unless activation
|
|
31
31
|
|
|
32
|
-
cache[mutant.stable_id] = build_recipe(mutant,
|
|
32
|
+
cache[mutant.stable_id] = build_recipe(mutant, activation)
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -53,13 +53,11 @@ module Henitai
|
|
|
53
53
|
class << self
|
|
54
54
|
private
|
|
55
55
|
|
|
56
|
-
def build_recipe(mutant,
|
|
56
|
+
def build_recipe(mutant, activation)
|
|
57
57
|
{
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
"methodType" => mutant.subject.method_type.to_s,
|
|
62
|
-
"sourceFile" => mutant.subject.source_file,
|
|
58
|
+
"activation" => activation.to_h,
|
|
59
|
+
"replacement" => mutant.mutated_source,
|
|
60
|
+
**subject_fields(mutant.subject),
|
|
63
61
|
"operator" => mutant.operator,
|
|
64
62
|
"description" => mutant.description,
|
|
65
63
|
"location" => serialize_location(mutant.location),
|
|
@@ -67,6 +65,15 @@ module Henitai
|
|
|
67
65
|
}
|
|
68
66
|
end
|
|
69
67
|
|
|
68
|
+
def subject_fields(subject)
|
|
69
|
+
{
|
|
70
|
+
"namespace" => subject.namespace,
|
|
71
|
+
"methodName" => subject.method_name,
|
|
72
|
+
"methodType" => subject.method_type.to_s,
|
|
73
|
+
"sourceFile" => subject.source_file
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
70
77
|
def serialize_location(location)
|
|
71
78
|
{
|
|
72
79
|
"file" => location[:file],
|