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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -1
  3. data/README.md +100 -13
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +36 -10
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +7 -5
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +225 -45
  121. metadata +46 -3
  122. 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
- # A survived verdict is the only retryable one: it is the verdict a flaky
8
- # test can fake, whereas a kill cannot be faked by flakiness. A requested
9
- # shutdown vetoes retries outright — respawning children while tearing the
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
- !shutdown && result.survived? && slot.retry_count < @max_retries
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
- test_files = test_file_selection.for(slot.mutant)
204
- with_worker_slot(slot.worker_index) do
205
- handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
206
- finish_retry(slot, handle)
207
- end
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.changed_files(from: @since, to: "HEAD") +
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
- def parse(source, path: DEFAULT_PATH)
35
- Prism::Translation::ParserCurrent.new.parse(source_buffer(source, path))
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
- # Ruby's file encoding rules apply here. Projects that use explicit source
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 ignored?(mutant, config)
70
- source = source_for(mutant)
71
- return false unless source
72
+ def ignore_pattern_for(mutant, config)
73
+ patterns = compiled_ignore_patterns(config)
74
+ return if patterns.empty?
72
75
 
73
- compiled_ignore_patterns(config).any? do |pattern|
74
- pattern.match?(source)
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
- return false unless ignored?(mutant, config)
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
- def source_for(mutant)
115
- original_node = mutant.original_node
116
- location = original_node&.location
117
- expression = location&.expression
118
- expression&.source
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
@@ -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(.+?)([#.])(\w+)\z/))
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
- collect_subject(node, namespace, singleton_context, subjects)
50
- traverse_children(node, namespace, singleton_context, subjects)
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 = constant_name(receiver)
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
- [qualify_namespace(namespace, constant_name(node.children.first)),
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