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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -1
  3. data/README.md +109 -16
  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 +57 -15
  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 +17 -7
  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 +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -6,7 +6,7 @@ require_relative "source_parser"
6
6
  module Henitai
7
7
  # Traverses a subject's AST and asks operators to build mutants.
8
8
  class MutantGenerator
9
- def generate(subjects, operators, config: nil)
9
+ def generate(subjects, operators, config: nil, sample: true)
10
10
  normalized_operators = normalize_operators(operators)
11
11
  arid_node_filter = AridNodeFilter.new
12
12
  syntax_validator = SyntaxValidator.new
@@ -22,7 +22,12 @@ module Henitai
22
22
  )
23
23
  end
24
24
 
25
- sample_mutants(mutants, config:, sampling_strategy:)
25
+ # Deduplicate programs before sampling: unsampled generation (survivor reruns) must keep the same
26
+ # representative of a duplicate program as a sampled run, or a reported survivor's stable id would
27
+ # not be found again. Sampling is stratified by operator within each subject, using a seeded
28
+ # per-mutant order, and returns kept mutants in input order.
29
+ unique = distinct_programs(distinct_identities(mutants))
30
+ sample ? sample_mutants(unique, config:, sampling_strategy:) : unique
26
31
  end
27
32
 
28
33
  private
@@ -71,14 +76,12 @@ module Henitai
71
76
  def walk(node, parent: nil)
72
77
  return unless node.is_a?(Parser::AST::Node)
73
78
 
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)
79
+ # A str node inside an interpolated string, heredoc, symbol, command,
80
+ # regexp or word array is raw text without quotes of its own.
81
+ # Replacing it would insert a quoted literal into the text -- a
82
+ # different mutation than reported, or a SyntaxError -- so it is not
83
+ # a StringLiteral site; its container is mutated instead.
84
+ return if raw_text_segment?(node, parent) || shorthand_value?(node, parent)
82
85
 
83
86
  apply_operators(node) if node_within_subject_range?(node)
84
87
  node.children.each do |child|
@@ -86,18 +89,29 @@ module Henitai
86
89
  end
87
90
  end
88
91
 
89
- def embedded_non_heredoc_dstr_str?(node, parent)
90
- node.type == :str &&
91
- parent&.type == :dstr &&
92
- !heredoc_node?(parent)
92
+ RAW_TEXT_CONTAINERS = %i[dstr dsym xstr regexp].freeze
93
+ private_constant :RAW_TEXT_CONTAINERS
94
+
95
+ def raw_text_segment?(node, parent)
96
+ return false unless node.type == :str && parent
97
+
98
+ RAW_TEXT_CONTAINERS.include?(parent.type) || word_array?(parent)
93
99
  end
94
100
 
95
- def heredoc_node?(node)
96
- node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
101
+ # The value of +build(config:)+ is an implicit call that shares its
102
+ # source range with the key; replacing it would rewrite the key.
103
+ def shorthand_value?(node, parent)
104
+ return false unless parent&.type == :pair && node.equal?(parent.children[1])
105
+
106
+ node.location.expression == parent.children[0].location.expression
107
+ end
108
+
109
+ def word_array?(node)
110
+ node.type == :array && node.location.begin&.source.to_s.start_with?("%")
97
111
  end
98
112
 
99
113
  def apply_operators(node)
100
- return if @arid_node_filter.suppressed?(node, @config)
114
+ return if @arid_node_filter.suppressed?(node)
101
115
 
102
116
  @operators_by_node_type[node.type].each do |operator|
103
117
  operator.mutate(node, subject: @subject).each do |mutant|
@@ -132,6 +146,35 @@ module Henitai
132
146
  end
133
147
  end
134
148
 
149
+ # Mutants sharing an identity are told apart by where their original node
150
+ # ends; those that also end at the same place are the same mutation
151
+ # twice, and only the first is kept.
152
+ def distinct_identities(mutants)
153
+ mutants.group_by { |mutant| MutantIdentity.stable_id(mutant) }.each_value do |group|
154
+ group.each { |mutant| mutant.identity_discriminator = node_end(mutant) } if group.size > 1
155
+ end
156
+ mutants.uniq { |mutant| MutantIdentity.stable_id(mutant) }
157
+ end
158
+
159
+ # Different operators can produce the same program -- BooleanLiteral's
160
+ # `return false` for `return true if x` is also ReturnValue's. Running it
161
+ # twice would count one result twice; the first mutant is kept. Mutants
162
+ # without a computable activation are left to the later checks.
163
+ def distinct_programs(mutants)
164
+ seen = {}
165
+ mutants.select do |mutant|
166
+ program = Mutant::Activator.activation_source_for(mutant)
167
+ key = [mutant.subject, program]
168
+ program.nil? || (!seen.key?(key) && (seen[key] = true))
169
+ end
170
+ end
171
+
172
+ def node_end(mutant)
173
+ range = mutant.original_node&.location&.expression
174
+ start = mutant.subject.source_range&.begin
175
+ range && start ? "#{range.last_line - start}:#{range.last_column}" : range&.end_pos.to_s
176
+ end
177
+
135
178
  def sample_mutants(mutants, config:, sampling_strategy:)
136
179
  sampling = config&.sampling
137
180
  return mutants unless sampling
@@ -140,7 +183,8 @@ module Henitai
140
183
  sampling_strategy.sample(
141
184
  mutants,
142
185
  ratio: sampling[:ratio],
143
- strategy: sampling[:strategy] || :stratified
186
+ strategy: sampling[:strategy] || :stratified,
187
+ seed: sampling[:seed] || SamplingStrategy::DEFAULT_SEED
144
188
  )
145
189
  end
146
190
 
@@ -29,7 +29,7 @@ module Henitai
29
29
  def killed_cache_bindings(mutant)
30
30
  [
31
31
  VerdictFingerprint.subject_source_hash(mutant),
32
- VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant))
32
+ VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant), context_sha: reuse_context&.fingerprint)
33
33
  ]
34
34
  end
35
35
 
@@ -38,34 +38,35 @@ module Henitai
38
38
  # which reflects selector fallback and test_excludes and would
39
39
  # permanently mismatch the live set. When the fingerprint cannot be
40
40
  # recomputed (no per-test map, recipe stubs without a source range),
41
- # the previously stored bindings are kept: the filter re-validates
42
- # everything live before reuse, so a carried-forward fingerprint can
43
- # never validate a changed state.
41
+ # the previously stored bindings are kept, but only from a survived
42
+ # row of the same subject source: a killed row's or an edited method's
43
+ # fingerprints would vouch for a state this survivor never ran in.
44
44
  def survived_cache_bindings(mutant, existing_row)
45
45
  bindings = [
46
46
  VerdictFingerprint.subject_source_hash(mutant),
47
47
  survived_tests_fingerprint(mutant)
48
48
  ]
49
49
  return bindings unless bindings.any?(&:nil?)
50
+ return [nil, nil] unless carry_survived_bindings?(bindings.first, existing_row)
50
51
 
51
52
  existing_cache_bindings(existing_row)
52
53
  end
53
54
 
55
+ def carry_survived_bindings?(source_hash, existing_row)
56
+ return false unless existing_row && existing_row["current_status"] == "survived"
57
+
58
+ source_hash.nil? || source_hash == existing_row["subject_source_hash"]
59
+ end
60
+
54
61
  def survived_tests_fingerprint(mutant)
55
62
  return nil unless per_test_coverage
56
63
 
57
64
  VerdictFingerprint.survivor_tests_fingerprint(
58
65
  per_test_coverage.tests_covering(mutant),
59
- dependency_sha: recording_dependency_sha
66
+ context_sha: reuse_context&.fingerprint
60
67
  )
61
68
  end
62
69
 
63
- # One dependency sha per record call set, computed lazily so runs
64
- # without survivors never touch the dependency files.
65
- def recording_dependency_sha
66
- @recording_dependency_sha ||= VerdictFingerprint.dependency_fingerprint
67
- end
68
-
69
70
  def existing_cache_bindings(existing_row)
70
71
  return [nil, nil] unless existing_row
71
72
 
@@ -17,9 +17,12 @@ module Henitai
17
17
  # @param per_test_coverage [PerTestCoverage, nil] live per-test coverage
18
18
  # view used to record the full-map intersection set for survived
19
19
  # verdicts; without it survived rows stay NULL (never reusable).
20
- def initialize(path:, per_test_coverage: nil)
20
+ # @param reuse_context [ReuseContext, nil] fingerprint recorded with every
21
+ # cached verdict; without it no verdict is cached (never reusable).
22
+ def initialize(path:, per_test_coverage: nil, reuse_context: nil)
21
23
  @path = path
22
24
  @per_test_coverage = per_test_coverage
25
+ @reuse_context = reuse_context
23
26
  end
24
27
 
25
28
  attr_reader :path
@@ -30,7 +33,7 @@ module Henitai
30
33
  with_database do |db|
31
34
  ensure_schema(db)
32
35
  db.transaction do
33
- insert_run(db, result, version, recorded_at) unless partial_rerun?(result)
36
+ insert_run(db, result, version, recorded_at) if full_run?(result)
34
37
  Array(result.mutants).each do |mutant|
35
38
  upsert_mutant(db, mutant, version, recorded_at)
36
39
  end
@@ -72,7 +75,7 @@ module Henitai
72
75
 
73
76
  private
74
77
 
75
- attr_reader :per_test_coverage
78
+ attr_reader :per_test_coverage, :reuse_context
76
79
 
77
80
  def verdict_from_row(row)
78
81
  return nil unless row && VerdictCache::CACHEABLE_STATUSES.include?(row["current_status"])
@@ -85,6 +88,11 @@ module Henitai
85
88
  }
86
89
  end
87
90
 
91
+ def full_run?(result)
92
+ authoritative = !result.respond_to?(:authoritative?) || result.authoritative?
93
+ authoritative && !partial_rerun?(result)
94
+ end
95
+
88
96
  def partial_rerun?(result)
89
97
  result.respond_to?(:partial_rerun?) && result.partial_rerun?
90
98
  end
@@ -133,8 +141,10 @@ module Henitai
133
141
  end
134
142
  end
135
143
 
144
+ # Recipe stubs carry a precomputed id that cannot be recomputed from
145
+ # their (absent) AST nodes.
136
146
  def stable_mutant_id(mutant)
137
- MutantIdentity.stable_id(mutant)
147
+ mutant.respond_to?(:stable_id) ? mutant.stable_id : MutantIdentity.stable_id(mutant)
138
148
  end
139
149
 
140
150
  def mutation_history_entry(mutant, version, recorded_at)
@@ -19,8 +19,12 @@ module Henitai
19
19
  digest(legacy_identity_components(mutant))
20
20
  end
21
21
 
22
+ # The discriminator is present only for mutants that would otherwise
23
+ # share an identity -- nested nodes starting at the same position, such
24
+ # as the calls of `x.a.b.c` -- so every other identity stays unchanged.
22
25
  def self.identity_components(mutant)
23
- legacy_identity_components(mutant) + [site_offset(mutant)]
26
+ discriminator = mutant.respond_to?(:identity_discriminator) && mutant.identity_discriminator
27
+ legacy_identity_components(mutant) + [site_offset(mutant)] + [discriminator].compact
24
28
  end
25
29
  private_class_method :identity_components
26
30
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Classifies one mutant run from the child's own report and the way the
5
+ # child process ended.
6
+ #
7
+ # The child reports what it got to: a failed activation, a finished test run
8
+ # with the exit code the tests returned, or a SystemExit raised while the
9
+ # tests ran. Only a finished test run yields a test verdict (Killed or
10
+ # Survived); everything else is a harness or runtime status that stays out of
11
+ # the detected count. In particular a child that died without a report, or
12
+ # was killed by a signal, is a RuntimeError, never a kill.
13
+ class MutantVerdict
14
+ def initialize(wait_result:, report:, stdout:, stderr:)
15
+ @wait_result = wait_result
16
+ @report = report
17
+ @output = "#{stdout}\n#{stderr}"
18
+ end
19
+
20
+ # @return [Array(Symbol, String|nil)] status and reason
21
+ def to_a
22
+ return [:timeout, nil] if @wait_result == :timeout
23
+ return [:runtime_error, "child terminated by signal #{signal_name}"] if signaled?
24
+ return [:runtime_error, "child exited with status #{exit_status} without reporting a result"] unless @report
25
+
26
+ reported_verdict
27
+ end
28
+
29
+ # @return [Array<String>] expanded paths of the test files that failed
30
+ # in the child, or an empty list when the child did not report them or
31
+ # also saw a failure it could not attribute to a file (which makes the
32
+ # kill genuine, never one a flaky file alone may have caused)
33
+ def killing_tests
34
+ return [] if @report.nil? || @report["unattributed_failures"]
35
+
36
+ Array(@report["failed_tests"]).map { |path| File.expand_path(path) }
37
+ end
38
+
39
+ private
40
+
41
+ def reported_verdict
42
+ case @report["outcome"]
43
+ when "activation_failed" then [:compile_error, "activation failed: #{@report['reason']}"]
44
+ when "environment_failed"
45
+ [:compile_error, "the test files failed to load, before the mutant was activated"]
46
+ when "system_exit"
47
+ [:runtime_error, "the test run ended early with SystemExit (status #{@report['status']})"]
48
+ when "tests_finished" then test_verdict
49
+ else [:runtime_error, "child reported an unknown outcome: #{@report['outcome']}"]
50
+ end
51
+ end
52
+
53
+ def test_verdict
54
+ return [:compile_error, "no examples ran"] if ExampleSummary.none_ran?(@output)
55
+
56
+ @report["exit_code"].to_i.zero? ? [:survived, nil] : [:killed, nil]
57
+ end
58
+
59
+ def signaled?
60
+ @wait_result.respond_to?(:signaled?) && @wait_result.signaled?
61
+ end
62
+
63
+ def signal_name
64
+ "SIG#{Signal.signame(@wait_result.termsig)}"
65
+ end
66
+
67
+ def exit_status
68
+ @wait_result.respond_to?(:exitstatus) ? @wait_result.exitstatus : "unknown"
69
+ end
70
+ end
71
+ end
@@ -12,7 +12,11 @@ module Henitai
12
12
  NODE_TYPES
13
13
  end
14
14
 
15
+ # A list without brackets (rescue classes, multiple return values,
16
+ # multiple assignment) is not an array literal and has no empty form.
15
17
  def mutate(node, subject:)
18
+ return [] unless node.location.begin
19
+
16
20
  if node.children.empty?
17
21
  [empty_array_mutant(subject:, node:)]
18
22
  else
@@ -51,6 +51,8 @@ module Henitai
51
51
  when :casgn
52
52
  namespace, name = left.children
53
53
  Parser::AST::Node.new(:casgn, [namespace, name, right])
54
+ when :indexasgn
55
+ left.updated(nil, [*left.children, right])
54
56
  when :send
55
57
  assignment_name = left.children[1] == :[] ? :[]= : :"#{left.children[1]}="
56
58
  receiver, _method_name, *arguments = left.children
@@ -28,6 +28,8 @@ module Henitai
28
28
  private
29
29
 
30
30
  def mutate_if(node, subject:)
31
+ return elsif_condition_mutants(node, subject:) if elsif?(node)
32
+
31
33
  condition, then_branch, else_branch = node.children
32
34
  mutations = condition_variants(node, subject:, condition:)
33
35
 
@@ -38,6 +40,22 @@ module Henitai
38
40
  mutations
39
41
  end
40
42
 
43
+ # An elsif's source range starts at `elsif` and has no `end`, so a
44
+ # re-rendered `if ... end` cannot take its place. Only its condition is
45
+ # mutated, in place; removing a branch is what "replaced condition with
46
+ # false" already does.
47
+ def elsif_condition_mutants(node, subject:)
48
+ condition = node.children.first
49
+ [[true_node, "replaced condition with true"], [false_node, "replaced condition with false"],
50
+ [negate(condition), "negated condition"]].map do |replacement, description|
51
+ build_mutant(subject:, original_node: condition, mutated_node: replacement, description:)
52
+ end
53
+ end
54
+
55
+ def elsif?(node)
56
+ node.location.respond_to?(:keyword) && node.location.keyword&.source == "elsif"
57
+ end
58
+
41
59
  def mutate_case(node, subject:)
42
60
  condition = node.children.first
43
61
  when_nodes, else_branch = case_children(node.children.drop(1))
@@ -154,8 +172,10 @@ module Henitai
154
172
  Parser::AST::Node.new(node.type, children)
155
173
  end
156
174
 
175
+ # The parentheses are part of the mutation: `!a > b` would negate only
176
+ # `a` and compare the boolean, which is not the reported mutant.
157
177
  def negate(node)
158
- Parser::AST::Node.new(:send, [node, :!])
178
+ Parser::AST::Node.new(:send, [Parser::AST::Node.new(:begin, [node]), :!])
159
179
  end
160
180
 
161
181
  # rubocop:disable Lint/BooleanSymbol
@@ -9,7 +9,10 @@ module Henitai
9
9
  # set) because framework key normalization makes it frequently unkillable
10
10
  # (ADR-12).
11
11
  class HashLiteral < Henitai::Operator
12
- NODE_TYPES = [:hash].freeze
12
+ # Keyword arguments (kwargs) are mutated like hash literals; the mutant
13
+ # keeps the node type, so a removed keyword never turns the remaining
14
+ # ones into a positional hash.
15
+ NODE_TYPES = %i[hash kwargs].freeze
13
16
 
14
17
  def self.node_types
15
18
  NODE_TYPES
@@ -29,8 +32,8 @@ module Henitai
29
32
  build_mutant(
30
33
  subject:,
31
34
  original_node: node,
32
- mutated_node: Parser::AST::Node.new(:hash, []),
33
- description: "replaced hash with empty hash"
35
+ mutated_node: node.updated(nil, []),
36
+ description: node.type == :kwargs ? "removed keyword arguments" : "replaced hash with empty hash"
34
37
  )
35
38
  end
36
39
 
@@ -52,7 +55,7 @@ module Henitai
52
55
 
53
56
  def hash_without_pair(node, pair_index)
54
57
  remaining = node.children.reject.with_index { |_pair, index| index == pair_index }
55
- Parser::AST::Node.new(:hash, remaining)
58
+ node.updated(nil, remaining)
56
59
  end
57
60
 
58
61
  # Pair keys are always AST nodes (sym/str/…); their first child is the
@@ -5,8 +5,11 @@ require_relative "../parser_current"
5
5
  module Henitai
6
6
  module Operators
7
7
  # Replaces generic method call results with nil.
8
+ #
9
+ # A call with a block is replaced together with its block: replacing only
10
+ # the call would leave +nil { ... }+ behind, which does not parse.
8
11
  class MethodExpression < Henitai::Operator
9
- NODE_TYPES = [:send].freeze
12
+ NODE_TYPES = %i[send block numblock].freeze
10
13
  EXCLUDED_METHODS = %i[
11
14
  +
12
15
  -
@@ -31,9 +34,10 @@ module Henitai
31
34
  end
32
35
 
33
36
  def mutate(node, subject:)
34
- return [] unless node.type == :send
37
+ call = called_method(node)
38
+ return [] unless call&.type == :send
35
39
 
36
- _receiver, method_name, *_arguments = node.children
40
+ _receiver, method_name, *_arguments = call.children
37
41
  return [] if excluded_method?(method_name)
38
42
 
39
43
  [
@@ -48,6 +52,13 @@ module Henitai
48
52
 
49
53
  private
50
54
 
55
+ def called_method(node)
56
+ case node.type
57
+ when :send then node
58
+ when :block, :numblock then node.children.first
59
+ end
60
+ end
61
+
51
62
  def excluded_method?(method_name)
52
63
  method_name.to_s.end_with?("=") || EXCLUDED_METHODS.include?(method_name)
53
64
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Operators
5
+ # Locates the regexp metacharacters that RegexMutator edits.
6
+ #
7
+ # An escaped character and the contents of a character class are literal
8
+ # text: the +^+ in +[^0-9]+ negates the class and is no anchor, and the
9
+ # +++ in +[+-]+ or +\++ is no quantifier. The scan therefore skips
10
+ # escapes and tracks character-class nesting, including POSIX brackets
11
+ # such as +[[:alpha:]]+.
12
+ class RegexMetacharacters
13
+ ANCHORS = %w[^ $].freeze
14
+ QUANTIFIERS = %w[+ * ?].freeze
15
+ GROUPING = %w[( |].freeze
16
+
17
+ def initialize(source)
18
+ @source = source
19
+ @found = Hash.new { |hash, key| hash[key] = [] }
20
+ scan
21
+ end
22
+
23
+ # Indices of the +char+ quantifier ("+" or "*") applied to an atom.
24
+ def quantifiers(char)
25
+ @found[[:quantifier, char]].dup
26
+ end
27
+
28
+ # Indices of the +char+ anchor ("^" or "$").
29
+ def anchors(char)
30
+ @found[[:anchor, char]].dup
31
+ end
32
+
33
+ # Indices of the opening brackets of top-level, non-negated classes.
34
+ def class_openers
35
+ @found[:class_opener].dup
36
+ end
37
+
38
+ private
39
+
40
+ def scan
41
+ @index = 0
42
+ @depth = 0
43
+ @previous = nil
44
+ step while @index < @source.length
45
+ end
46
+
47
+ def step
48
+ char = @source[@index]
49
+ if char == "\\"
50
+ @previous = :atom
51
+ @index += 2
52
+ else
53
+ @depth.positive? ? within_class(char) : top_level(char)
54
+ @index += 1
55
+ end
56
+ end
57
+
58
+ def within_class(char)
59
+ @depth += 1 if char == "["
60
+ @depth -= 1 if char == "]"
61
+ @previous = :atom if @depth.zero?
62
+ end
63
+
64
+ def top_level(char)
65
+ record(char)
66
+ @previous = kind_of_token(char)
67
+ @depth = 1 if char == "["
68
+ end
69
+
70
+ def record(char)
71
+ if ANCHORS.include?(char)
72
+ @found[[:anchor, char]] << @index
73
+ elsif char == "["
74
+ @found[:class_opener] << @index unless @source[@index + 1] == "^"
75
+ elsif QUANTIFIERS.include?(char) && @previous == :atom
76
+ @found[[:quantifier, char]] << @index
77
+ end
78
+ end
79
+
80
+ def kind_of_token(char)
81
+ return :anchor if ANCHORS.include?(char)
82
+ return :quantifier if QUANTIFIERS.include?(char) || char == "}"
83
+ return :group if GROUPING.include?(char)
84
+
85
+ :atom
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../parser_current"
4
+ require_relative "regex_metacharacters"
4
5
 
5
6
  module Henitai
6
7
  module Operators
@@ -8,7 +9,10 @@ module Henitai
8
9
  # and character-class negation.
9
10
  #
10
11
  # Each applicable transformation yields a separate mutant. Invalid
11
- # results (unparseable regex) are discarded before emission.
12
+ # results (unparseable regex) are discarded before emission. Only static,
13
+ # single-segment literals are mutated: an interpolated or multi-line
14
+ # regexp is split into several parts, and rewriting one part alone would
15
+ # drop or misread the others.
12
16
  # Anchor removal and character-class negation are intentionally one-way:
13
17
  # they reduce noisy patterns rather than mirroring a full edit matrix.
14
18
  class RegexMutator < Henitai::Operator
@@ -30,11 +34,11 @@ module Henitai
30
34
  private
31
35
 
32
36
  def extract_parts(node)
33
- str_child = node.children.find { |c| c.is_a?(Parser::AST::Node) && c.type == :str }
34
- opts_node = node.children.find { |c| c.is_a?(Parser::AST::Node) && c.type == :regopt }
35
- return nil unless str_child
37
+ *parts, opts_node = node.children
38
+ return unless opts_node.is_a?(Parser::AST::Node) && opts_node.type == :regopt
39
+ return unless parts.size == 1 && parts.first.is_a?(Parser::AST::Node) && parts.first.type == :str
36
40
 
37
- [str_child.children[0], opts_node]
41
+ [parts.first.children[0], opts_node]
38
42
  end
39
43
 
40
44
  def build_regex_mutant(node, opts_node, new_source, original, description, subject:) # rubocop:disable Metrics/ParameterLists
@@ -47,35 +51,26 @@ module Henitai
47
51
  end
48
52
 
49
53
  def transformations(source)
54
+ found = RegexMetacharacters.new(source)
50
55
  [
51
- *quantifier_swaps(source),
52
- *anchor_removals(source),
53
- *char_class_negations(source)
56
+ [edit(source, found.quantifiers("+"), "*"), "replaced + quantifier with *"],
57
+ [edit(source, found.quantifiers("*"), "+"), "replaced * quantifier with +"],
58
+ # Anchors are removed, never added, and character classes are only
59
+ # negated: the reverse edits would just recreate an input.
60
+ [edit(source, found.anchors("^").first(1), ""), "removed ^ anchor"],
61
+ [edit(source, found.anchors("$").last(1), ""), "removed $ anchor"],
62
+ [insert_after(source, found.class_openers, "^"), "negated character class"]
54
63
  ]
55
64
  end
56
65
 
57
- def quantifier_swaps(source)
58
- # Quantifier swaps are symmetric. The other transforms are intentionally
59
- # one-way because they model reductions instead of reversible edits.
60
- [
61
- [source.gsub(/(?<=[^*+?\\])\+/, "*"), "replaced + quantifier with *"],
62
- [source.gsub(/(?<=[^*+?\\])\*/, "+"), "replaced * quantifier with +"]
63
- ]
64
- end
65
-
66
- def anchor_removals(source)
67
- # Anchors are removed, not added back. The mutation is deliberately
68
- # asymmetric because "restoring" anchors would just recreate the input.
69
- [
70
- [source.sub("^", ""), "removed ^ anchor"],
71
- [source.sub(/\$$/, ""), "removed $ anchor"]
72
- ]
66
+ # Replaces the single characters at +indices+, from the last to the
67
+ # first so earlier indices stay valid.
68
+ def edit(source, indices, text)
69
+ indices.sort.reverse.each_with_object(source.dup) { |index, result| result[index] = text }
73
70
  end
74
71
 
75
- def char_class_negations(source)
76
- # Negation only flips a positive class into a negated one. We do not
77
- # emit the reverse mutation because that would mirror the same edit.
78
- [[source.gsub(/\[(?!\^)/, "[^"), "negated character class"]]
72
+ def insert_after(source, indices, text)
73
+ indices.sort.reverse.each_with_object(source.dup) { |index, result| result.insert(index + 1, text) }
79
74
  end
80
75
 
81
76
  def valid_regex?(source)
@@ -47,7 +47,7 @@ module Henitai
47
47
  end
48
48
 
49
49
  def mutate_implicit_return(node, subject:)
50
- return [] unless node == final_expression_node(subject&.ast_node)
50
+ return [] unless node.equal?(final_expression_node(subject&.ast_node))
51
51
 
52
52
  replacement_nodes(node).map do |replacement|
53
53
  build_mutant(
@@ -24,6 +24,7 @@ module Henitai
24
24
  autoload :MethodExpression, "henitai/operators/method_expression"
25
25
  autoload :AssignmentExpression, "henitai/operators/assignment_expression"
26
26
  autoload :MethodChainUnwrap, "henitai/operators/method_chain_unwrap"
27
+ autoload :RegexMetacharacters, "henitai/operators/regex_metacharacters"
27
28
  autoload :RegexMutator, "henitai/operators/regex_mutator"
28
29
  autoload :UnaryOperator, "henitai/operators/unary_operator"
29
30
  autoload :UpdateOperator, "henitai/operators/update_operator"