henitai 0.1.8 → 0.2.0

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