henitai 0.1.8 → 0.2.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 +86 -1
- data/README.md +18 -4
- data/lib/henitai/cli.rb +81 -3
- data/lib/henitai/configuration.rb +24 -11
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/execution_engine.rb +3 -9
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration.rb +403 -38
- data/lib/henitai/mutant/activator.rb +14 -2
- data/lib/henitai/mutant.rb +13 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store.rb +7 -22
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +434 -0
- data/lib/henitai/reporter.rb +76 -3
- data/lib/henitai/result.rb +39 -8
- data/lib/henitai/runner.rb +203 -14
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +205 -9
- metadata +23 -2
|
@@ -6,6 +6,7 @@ require "unparser"
|
|
|
6
6
|
module Henitai
|
|
7
7
|
class Mutant
|
|
8
8
|
# Activates a mutant inside the forked child process.
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
10
|
class Activator
|
|
10
11
|
# Filters "already initialized constant" C-level warnings that fire when
|
|
11
12
|
# a source file is loaded into a process that already has the constant
|
|
@@ -38,16 +39,26 @@ module Henitai
|
|
|
38
39
|
new.activate!(mutant)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Returns the +define_method+ source string for +mutant+ without
|
|
43
|
+
# actually evaluating it. Used to pre-compute activation recipes.
|
|
44
|
+
# Returns nil if the source cannot be computed (e.g. unsupported AST node).
|
|
45
|
+
def self.activation_source_for(mutant)
|
|
46
|
+
new.send(:method_source, mutant)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def activate!(mutant)
|
|
42
52
|
subject = mutant.subject
|
|
43
53
|
raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
|
|
44
54
|
|
|
55
|
+
source = mutant.precomputed_activation_source || method_source(mutant)
|
|
45
56
|
target = target_for(subject)
|
|
46
57
|
Henitai::WarningSilencer.silence do
|
|
47
|
-
target.class_eval(
|
|
58
|
+
target.class_eval(source, __FILE__, __LINE__ + 1)
|
|
48
59
|
nil
|
|
49
60
|
end
|
|
50
|
-
rescue Unparser::UnsupportedNodeError
|
|
61
|
+
rescue Unparser::UnsupportedNodeError, SyntaxError
|
|
51
62
|
:compile_error
|
|
52
63
|
end
|
|
53
64
|
|
|
@@ -266,5 +277,6 @@ module Henitai
|
|
|
266
277
|
raise Unparser::UnsupportedNodeError, e.message
|
|
267
278
|
end
|
|
268
279
|
end
|
|
280
|
+
# rubocop:enable Metrics/ClassLength
|
|
269
281
|
end
|
|
270
282
|
end
|
data/lib/henitai/mutant.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "mutant_identity"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
# Represents a single syntactic mutation applied to a Subject.
|
|
@@ -34,7 +35,8 @@ module Henitai
|
|
|
34
35
|
].freeze
|
|
35
36
|
|
|
36
37
|
attr_reader :id, :subject, :operator, :original_node, :mutated_node,
|
|
37
|
-
:mutation_type, :description, :location
|
|
38
|
+
:mutation_type, :description, :location,
|
|
39
|
+
:precomputed_stable_id, :precomputed_activation_source
|
|
38
40
|
attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
|
|
39
41
|
|
|
40
42
|
# @param subject [Subject] the subject being mutated
|
|
@@ -42,7 +44,9 @@ module Henitai
|
|
|
42
44
|
# @param nodes [Hash] AST nodes with :original and :mutated entries
|
|
43
45
|
# @param description [String] human-readable description of the mutation
|
|
44
46
|
# @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
|
|
45
|
-
|
|
47
|
+
# rubocop:disable Metrics/ParameterLists
|
|
48
|
+
def initialize(subject:, operator:, nodes:, description:, location:,
|
|
49
|
+
precomputed_stable_id: nil, precomputed_activation_source: nil)
|
|
46
50
|
@id = SecureRandom.uuid
|
|
47
51
|
@subject = subject
|
|
48
52
|
@operator = operator
|
|
@@ -50,12 +54,19 @@ module Henitai
|
|
|
50
54
|
@mutated_node = nodes.fetch(:mutated)
|
|
51
55
|
@description = description
|
|
52
56
|
@location = location
|
|
57
|
+
@precomputed_stable_id = precomputed_stable_id
|
|
58
|
+
@precomputed_activation_source = precomputed_activation_source
|
|
53
59
|
@status = :pending
|
|
54
60
|
@killing_test = nil
|
|
55
61
|
@duration = nil
|
|
56
62
|
@covered_by = nil
|
|
57
63
|
@tests_completed = nil
|
|
58
64
|
end
|
|
65
|
+
# rubocop:enable Metrics/ParameterLists
|
|
66
|
+
|
|
67
|
+
def stable_id
|
|
68
|
+
@stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
|
|
69
|
+
end
|
|
59
70
|
|
|
60
71
|
def killed? = @status == :killed
|
|
61
72
|
def survived? = @status == :survived
|
|
@@ -68,15 +68,34 @@ module Henitai
|
|
|
68
68
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
|
-
def walk(node)
|
|
71
|
+
def walk(node, parent: nil)
|
|
72
72
|
return unless node.is_a?(Parser::AST::Node)
|
|
73
73
|
|
|
74
|
+
# Str children of a non-heredoc dstr are raw text segments embedded
|
|
75
|
+
# inside a quoted interpolated string. They have no surrounding quotes
|
|
76
|
+
# in the source, so replacing them via source-fragment substitution
|
|
77
|
+
# would insert a quoted literal into the raw-text position and produce
|
|
78
|
+
# a SyntaxError when the mutant is activated.
|
|
79
|
+
# Heredoc dstr children are exempt: the heredoc body is plain text, so
|
|
80
|
+
# inserting "" there stays valid Ruby.
|
|
81
|
+
return if embedded_non_heredoc_dstr_str?(node, parent)
|
|
82
|
+
|
|
74
83
|
apply_operators(node) if node_within_subject_range?(node)
|
|
75
84
|
node.children.each do |child|
|
|
76
|
-
walk(child)
|
|
85
|
+
walk(child, parent: node)
|
|
77
86
|
end
|
|
78
87
|
end
|
|
79
88
|
|
|
89
|
+
def embedded_non_heredoc_dstr_str?(node, parent)
|
|
90
|
+
node.type == :str &&
|
|
91
|
+
parent&.type == :dstr &&
|
|
92
|
+
!heredoc_node?(parent)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def heredoc_node?(node)
|
|
96
|
+
node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
|
|
97
|
+
end
|
|
98
|
+
|
|
80
99
|
def apply_operators(node)
|
|
81
100
|
return if @arid_node_filter.suppressed?(node, @config)
|
|
82
101
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
-
require "digest"
|
|
5
4
|
require "fileutils"
|
|
6
5
|
require "json"
|
|
7
6
|
require "sqlite3"
|
|
8
7
|
require "time"
|
|
9
|
-
|
|
8
|
+
require_relative "mutant_identity"
|
|
10
9
|
|
|
11
10
|
module Henitai
|
|
12
11
|
# Persists mutant outcomes across runs in a lightweight SQLite database.
|
|
@@ -87,7 +86,7 @@ module Henitai
|
|
|
87
86
|
with_database do |db|
|
|
88
87
|
ensure_schema(db)
|
|
89
88
|
db.transaction do
|
|
90
|
-
insert_run(db, result, version, recorded_at)
|
|
89
|
+
insert_run(db, result, version, recorded_at) unless partial_rerun?(result)
|
|
91
90
|
Array(result.mutants).each do |mutant|
|
|
92
91
|
upsert_mutant(db, mutant, version, recorded_at)
|
|
93
92
|
end
|
|
@@ -108,6 +107,10 @@ module Henitai
|
|
|
108
107
|
|
|
109
108
|
private
|
|
110
109
|
|
|
110
|
+
def partial_rerun?(result)
|
|
111
|
+
result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
112
|
+
end
|
|
113
|
+
|
|
111
114
|
def with_database
|
|
112
115
|
db = SQLite3::Database.new(path)
|
|
113
116
|
db.results_as_hash = true
|
|
@@ -140,25 +143,7 @@ module Henitai
|
|
|
140
143
|
end
|
|
141
144
|
|
|
142
145
|
def stable_mutant_id(mutant)
|
|
143
|
-
|
|
144
|
-
[
|
|
145
|
-
mutant.subject.expression,
|
|
146
|
-
mutant.operator,
|
|
147
|
-
mutant.description,
|
|
148
|
-
mutant.location[:file],
|
|
149
|
-
mutant.location[:start_line],
|
|
150
|
-
mutant.location[:end_line],
|
|
151
|
-
mutant.location[:start_col],
|
|
152
|
-
mutant.location[:end_col],
|
|
153
|
-
mutation_signature(mutant)
|
|
154
|
-
].join("\0")
|
|
155
|
-
)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def mutation_signature(mutant)
|
|
159
|
-
Unparser.unparse(mutant.mutated_node)
|
|
160
|
-
rescue StandardError
|
|
161
|
-
mutant.mutated_node.class.name
|
|
146
|
+
MutantIdentity.stable_id(mutant)
|
|
162
147
|
end
|
|
163
148
|
|
|
164
149
|
def mutation_history_entry(mutant, version, recorded_at)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "unparser"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
# Computes a stable, run-independent SHA256 identity for a mutant.
|
|
8
|
+
#
|
|
9
|
+
# The identity is derived from the mutant's semantic content, not the
|
|
10
|
+
# session UUID or source coordinates, so it survives ordinary line shifts.
|
|
11
|
+
module MutantIdentity
|
|
12
|
+
def self.stable_id(mutant)
|
|
13
|
+
Digest::SHA256.hexdigest(identity_components(mutant).join("\0"))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.identity_components(mutant)
|
|
17
|
+
[
|
|
18
|
+
mutant.subject.expression,
|
|
19
|
+
mutant.operator,
|
|
20
|
+
mutant.description,
|
|
21
|
+
mutant.location[:file],
|
|
22
|
+
mutation_signature(mutant)
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
private_class_method :identity_components
|
|
26
|
+
|
|
27
|
+
def self.mutation_signature(mutant)
|
|
28
|
+
Unparser.unparse(mutant.mutated_node)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
mutant.mutated_node.class.name
|
|
31
|
+
end
|
|
32
|
+
private_class_method :mutation_signature
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -31,15 +31,26 @@ module Henitai
|
|
|
31
31
|
start_parallel_stdin_watcher(context, stdin_pipe)
|
|
32
32
|
parallel_workers(context, process_mutant).each(&:join)
|
|
33
33
|
ensure
|
|
34
|
+
teardown_parallel_execution(context)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :worker_count
|
|
40
|
+
|
|
41
|
+
def teardown_parallel_execution(context)
|
|
34
42
|
stop_parallel_stdin_watcher(context)
|
|
35
43
|
restore_parallel_signal_traps(context)
|
|
44
|
+
emit_scheduler_diagnostics if Integration::SchedulerDiagnostics.enabled?
|
|
36
45
|
raise context.state[:error] if context&.state&.fetch(:error, nil)
|
|
37
46
|
raise Interrupt if context&.state&.fetch(:stopping, false)
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
def emit_scheduler_diagnostics
|
|
50
|
+
summary = Integration::SchedulerDiagnostics.summary
|
|
51
|
+
warn "[henitai-scheduler] max_concurrent_children=#{summary[:max_concurrent]}"
|
|
52
|
+
warn "[henitai-scheduler] child_intervals=#{summary[:intervals].inspect}"
|
|
53
|
+
end
|
|
43
54
|
|
|
44
55
|
def build_parallel_queue(mutants)
|
|
45
56
|
Queue.new.tap { |queue| mutants.each { |mutant| queue << mutant } }
|
|
@@ -84,20 +95,16 @@ module Henitai
|
|
|
84
95
|
end
|
|
85
96
|
|
|
86
97
|
def parallel_workers(context, process_mutant)
|
|
87
|
-
Array.new(worker_count)
|
|
98
|
+
Array.new(worker_count) do
|
|
99
|
+
Thread.new { process_parallel_worker(context, process_mutant) }
|
|
100
|
+
end
|
|
88
101
|
end
|
|
89
102
|
|
|
90
103
|
def process_parallel_worker(context, process_mutant)
|
|
91
104
|
loop do
|
|
92
105
|
break if context.state[:stopping]
|
|
93
106
|
|
|
94
|
-
process_mutant
|
|
95
|
-
context.queue.pop(true),
|
|
96
|
-
context.integration,
|
|
97
|
-
context.config,
|
|
98
|
-
context.progress_reporter,
|
|
99
|
-
context.mutex
|
|
100
|
-
)
|
|
107
|
+
run_one_mutant(context, process_mutant)
|
|
101
108
|
rescue ThreadError
|
|
102
109
|
break
|
|
103
110
|
rescue StandardError => e
|
|
@@ -106,6 +113,17 @@ module Henitai
|
|
|
106
113
|
end
|
|
107
114
|
end
|
|
108
115
|
|
|
116
|
+
def run_one_mutant(context, process_mutant)
|
|
117
|
+
mutant = context.queue.pop(true)
|
|
118
|
+
process_mutant.call(
|
|
119
|
+
mutant,
|
|
120
|
+
context.integration,
|
|
121
|
+
context.config,
|
|
122
|
+
context.progress_reporter,
|
|
123
|
+
context.mutex
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
109
127
|
def stop_parallel_stdin_watcher(context)
|
|
110
128
|
context&.stdin_watcher&.kill
|
|
111
129
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Wakeup pipe used to interrupt child-process wait loops when CHLD arrives.
|
|
5
|
+
class ProcessWakeup
|
|
6
|
+
def initialize(signal_name: "CHLD")
|
|
7
|
+
@signal_name = signal_name
|
|
8
|
+
@reader, @writer = IO.pipe
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def install
|
|
12
|
+
@previous_handler = Signal.trap(signal_name) { signal }
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def wait(timeout)
|
|
17
|
+
# rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
|
|
18
|
+
IO.select([reader], nil, nil, timeout)
|
|
19
|
+
# rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
|
|
20
|
+
rescue Errno::EINTR
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def drain
|
|
25
|
+
loop do
|
|
26
|
+
reader.read_nonblock(4096)
|
|
27
|
+
end
|
|
28
|
+
rescue IO::WaitReadable, EOFError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def signal
|
|
33
|
+
writer.write_nonblock(".")
|
|
34
|
+
rescue IO::WaitWritable, IOError, Errno::EPIPE
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
Signal.trap(signal_name, previous_handler) if previous_handler
|
|
40
|
+
ensure
|
|
41
|
+
reader.close unless reader.closed?
|
|
42
|
+
writer.close unless writer.closed?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :previous_handler, :reader, :signal_name, :writer
|
|
48
|
+
end
|
|
49
|
+
end
|