henitai 0.5.3 → 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 +222 -1
- data/README.md +100 -13
- 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 +36 -10
- 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 +7 -5
- 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 +225 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -8,6 +8,9 @@ module Henitai
|
|
|
8
8
|
# scheduler remains the single caller of the process table. Mixed into
|
|
9
9
|
# {SlotScheduler}; relies on its +runtime+ reader.
|
|
10
10
|
module ProcessControl
|
|
11
|
+
GROUP_POLL_INTERVAL = 0.02
|
|
12
|
+
GROUP_POLL_COUNT = 10
|
|
13
|
+
|
|
11
14
|
private
|
|
12
15
|
|
|
13
16
|
def monotonic_time
|
|
@@ -33,6 +36,36 @@ module Henitai
|
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
# Terminates whatever a finished child left behind in its process group
|
|
40
|
+
# (daemons, grandchildren). Waits at most PROCESS_DRAIN_WINDOW for them to
|
|
41
|
+
# exit on SIGTERM before escalating to SIGKILL.
|
|
42
|
+
def tear_down_process_group(pid)
|
|
43
|
+
signal_process_group(pid, :SIGTERM)
|
|
44
|
+
return unless group_members_remain?(pid)
|
|
45
|
+
|
|
46
|
+
signal_process_group(pid, :SIGKILL)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def group_members_remain?(pid)
|
|
50
|
+
GROUP_POLL_COUNT.times do
|
|
51
|
+
return false unless process_group_alive?(pid)
|
|
52
|
+
|
|
53
|
+
runtime.pause(GROUP_POLL_INTERVAL)
|
|
54
|
+
end
|
|
55
|
+
process_group_alive?(pid)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Signal 0 probes existence only. ESRCH means the group is empty; EPERM
|
|
59
|
+
# means members exist but belong to someone else.
|
|
60
|
+
def process_group_alive?(pid)
|
|
61
|
+
runtime.kill(0, -pid)
|
|
62
|
+
true
|
|
63
|
+
rescue Errno::ESRCH
|
|
64
|
+
false
|
|
65
|
+
rescue Errno::EPERM
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
36
69
|
def reap_pid(pid)
|
|
37
70
|
runtime.wait(pid)
|
|
38
71
|
rescue Errno::ECHILD, Errno::ESRCH
|
|
@@ -4,17 +4,19 @@ module Henitai
|
|
|
4
4
|
class SlotScheduler
|
|
5
5
|
# Decides whether a finished slot earns another attempt.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# run down would leak processes past the drain window.
|
|
7
|
+
# The rule itself lives in FlakyRetryDecision; this class adds the shutdown
|
|
8
|
+
# veto. A requested shutdown vetoes retries outright — respawning children
|
|
9
|
+
# while tearing the run down would leak processes past the drain window.
|
|
11
10
|
class RetryPolicy
|
|
12
|
-
def initialize(max_retries:)
|
|
11
|
+
def initialize(max_retries:, flaky_tests: FlakyTestRegistry.new([]))
|
|
13
12
|
@max_retries = max_retries.to_i
|
|
13
|
+
@decision = FlakyRetryDecision.new(flaky_tests: flaky_tests, max_retries: @max_retries)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def retry?(slot:, result:, shutdown:)
|
|
17
|
-
|
|
17
|
+
return false if shutdown || result.status != :killed
|
|
18
|
+
|
|
19
|
+
@decision.retry?(status: :killed, killing_tests: result.killing_tests, attempts: slot.retry_count)
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
end
|
|
@@ -51,7 +51,8 @@ module Henitai
|
|
|
51
51
|
# rubocop:disable Metrics/ParameterLists -- every one of these is a distinct
|
|
52
52
|
# collaborator supplied by ProcessWorkerRunner; bundling them into a context
|
|
53
53
|
# object would only move the list.
|
|
54
|
-
def initialize(integration:, config:, progress_reporter:, options:, host:, slot_table: SlotTable.new
|
|
54
|
+
def initialize(integration:, config:, progress_reporter:, options:, host:, slot_table: SlotTable.new,
|
|
55
|
+
flaky_tests: nil)
|
|
55
56
|
# rubocop:enable Metrics/ParameterLists
|
|
56
57
|
@integration = integration
|
|
57
58
|
@config = config
|
|
@@ -59,6 +60,7 @@ module Henitai
|
|
|
59
60
|
@options = options
|
|
60
61
|
@host = host
|
|
61
62
|
@slot_table = slot_table
|
|
63
|
+
@injected_flaky_tests = flaky_tests
|
|
62
64
|
@pending = []
|
|
63
65
|
@results = []
|
|
64
66
|
@flaky_retry_count = 0
|
|
@@ -119,7 +121,7 @@ module Henitai
|
|
|
119
121
|
worker_index = slot_table.next_free_worker_index(worker_count)
|
|
120
122
|
with_worker_slot(worker_index) do
|
|
121
123
|
handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
|
|
122
|
-
register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
|
|
124
|
+
register_slot(handle, mutant, worker_index, handle.timeout || slot_timeout(mutant, test_files))
|
|
123
125
|
end
|
|
124
126
|
rescue StandardError => e
|
|
125
127
|
record_spawn_failure(mutant, e)
|
|
@@ -154,14 +156,21 @@ module Henitai
|
|
|
154
156
|
resolver ? resolver.call(mutant, test_files) : config.timeout
|
|
155
157
|
end
|
|
156
158
|
|
|
159
|
+
# Every path that reaps a mutant child ends here, so this is the single
|
|
160
|
+
# place that tears down the child's process group. A reaped pid that owns
|
|
161
|
+
# no slot (wait2(-1) can reap any helper child) is never signalled.
|
|
157
162
|
def complete_slot(pid, wait_result)
|
|
158
163
|
slot_id = slot_table.release_pid(pid)
|
|
159
164
|
return unless slot_id
|
|
160
165
|
|
|
166
|
+
tear_down_process_group(pid)
|
|
167
|
+
|
|
161
168
|
slot = slot_table.fetch(slot_id)
|
|
162
169
|
return unless slot
|
|
163
170
|
|
|
164
171
|
Integration::SchedulerDiagnostics.child_ended(pid)
|
|
172
|
+
return rerun_lost_slot(slot) if lost?(wait_result)
|
|
173
|
+
|
|
165
174
|
result = integration.build_result(wait_result, slot.log_paths)
|
|
166
175
|
dispatch_slot_result(slot, result)
|
|
167
176
|
end
|
|
@@ -175,6 +184,7 @@ module Henitai
|
|
|
175
184
|
def finalize_slot(slot, result)
|
|
176
185
|
slot_table.delete(slot.slot_id)
|
|
177
186
|
slot.mutant.status = result.status
|
|
187
|
+
ScenarioExecutionResult.copy_reason(result, slot.mutant)
|
|
178
188
|
results << result
|
|
179
189
|
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
180
190
|
result.release_output! if result.respond_to?(:release_output!)
|
|
@@ -184,7 +194,13 @@ module Henitai
|
|
|
184
194
|
# the runner builds when nothing is pending) pass `config: nil`, and this
|
|
185
195
|
# is only reached once a slot has actually finished.
|
|
186
196
|
def retry_policy
|
|
187
|
-
@retry_policy ||= RetryPolicy.new(max_retries: config.max_flaky_retries)
|
|
197
|
+
@retry_policy ||= RetryPolicy.new(max_retries: config.max_flaky_retries, flaky_tests: flaky_tests)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def flaky_tests
|
|
201
|
+
return @injected_flaky_tests if @injected_flaky_tests
|
|
202
|
+
|
|
203
|
+
FlakyTestRegistry.for_config(config)
|
|
188
204
|
end
|
|
189
205
|
|
|
190
206
|
def drain_verdict
|
|
@@ -199,20 +215,43 @@ module Henitai
|
|
|
199
215
|
@test_file_selection ||= TestFileSelection.new(options: options, integration: integration)
|
|
200
216
|
end
|
|
201
217
|
|
|
218
|
+
# The fork server died while the mutant ran, so nothing is known about
|
|
219
|
+
# it. It is run again on the local path and never becomes a verdict.
|
|
220
|
+
def lost?(wait_result)
|
|
221
|
+
wait_result.respond_to?(:lost?) && wait_result.lost?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def rerun_lost_slot(slot)
|
|
225
|
+
return slot_table.delete(slot.slot_id) if shutdown?
|
|
226
|
+
|
|
227
|
+
respawn_slot(slot) { |handle| restart_slot(slot, handle) }
|
|
228
|
+
end
|
|
229
|
+
|
|
202
230
|
def retry_slot(slot)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
231
|
+
respawn_slot(slot) { |handle| finish_retry(slot, handle) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def respawn_slot(slot)
|
|
235
|
+
with_worker_slot(slot.worker_index) { yield respawn_handle(slot) }
|
|
208
236
|
rescue StandardError => e
|
|
209
237
|
slot_table.delete(slot.slot_id)
|
|
210
238
|
record_spawn_failure(slot.mutant, e)
|
|
211
239
|
end
|
|
212
240
|
|
|
241
|
+
def respawn_handle(slot)
|
|
242
|
+
test_files = test_file_selection.for(slot.mutant)
|
|
243
|
+
handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
|
|
244
|
+
slot.timeout = handle.timeout || slot_timeout(slot.mutant, test_files)
|
|
245
|
+
handle
|
|
246
|
+
end
|
|
247
|
+
|
|
213
248
|
def finish_retry(slot, handle)
|
|
214
249
|
@flaky_retry_count += 1 if slot.retry_count.zero?
|
|
215
250
|
slot.retry_count += 1
|
|
251
|
+
restart_slot(slot, handle)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def restart_slot(slot, handle)
|
|
216
255
|
reset_slot_for_retry(slot, handle)
|
|
217
256
|
slot_table.register_pid(handle.pid, slot.slot_id)
|
|
218
257
|
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
@@ -58,7 +58,7 @@ module Henitai
|
|
|
58
58
|
# dirty and untracked files): the working tree is what gets tested, so it is
|
|
59
59
|
# always part of "changed since REF".
|
|
60
60
|
def changed_paths_since
|
|
61
|
-
@git_diff_analyzer.
|
|
61
|
+
@git_diff_analyzer.changed_files_since(@since) +
|
|
62
62
|
@git_diff_analyzer.working_tree_changed_files
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -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
|