henitai 0.1.10 → 0.2.1

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +33 -7
  4. data/assets/schema/henitai.schema.json +6 -0
  5. data/lib/henitai/cli/clean_command.rb +48 -0
  6. data/lib/henitai/cli/command_support.rb +51 -0
  7. data/lib/henitai/cli/init_command.rb +64 -0
  8. data/lib/henitai/cli/operator_command.rb +95 -0
  9. data/lib/henitai/cli/options.rb +120 -0
  10. data/lib/henitai/cli/run_command.rb +103 -0
  11. data/lib/henitai/cli.rb +17 -327
  12. data/lib/henitai/configuration.rb +26 -12
  13. data/lib/henitai/configuration_validator/rules.rb +143 -0
  14. data/lib/henitai/configuration_validator/scalars.rb +123 -0
  15. data/lib/henitai/configuration_validator.rb +12 -239
  16. data/lib/henitai/coverage_bootstrapper.rb +24 -24
  17. data/lib/henitai/eager_load.rb +36 -5
  18. data/lib/henitai/execution_engine.rb +6 -11
  19. data/lib/henitai/git_diff_analyzer.rb +34 -0
  20. data/lib/henitai/integration/base.rb +171 -0
  21. data/lib/henitai/integration/child_debug_support.rb +115 -0
  22. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  23. data/lib/henitai/integration/coverage_suppression.rb +43 -0
  24. data/lib/henitai/integration/minitest.rb +133 -0
  25. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  26. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  27. data/lib/henitai/integration/rspec_process_runner.rb +66 -13
  28. data/lib/henitai/integration/rspec_test_selection.rb +135 -0
  29. data/lib/henitai/integration/scenario_log_support.rb +116 -0
  30. data/lib/henitai/integration.rb +43 -519
  31. data/lib/henitai/mutant/activator.rb +13 -79
  32. data/lib/henitai/mutant/parameter_source.rb +98 -0
  33. data/lib/henitai/mutant.rb +14 -2
  34. data/lib/henitai/mutant_generator.rb +21 -2
  35. data/lib/henitai/mutant_history_store/sql.rb +72 -0
  36. data/lib/henitai/mutant_history_store.rb +12 -91
  37. data/lib/henitai/mutant_identity.rb +34 -0
  38. data/lib/henitai/parallel_execution_runner.rb +29 -11
  39. data/lib/henitai/per_test_coverage_collector.rb +3 -1
  40. data/lib/henitai/process_wakeup.rb +49 -0
  41. data/lib/henitai/process_worker_runner.rb +148 -0
  42. data/lib/henitai/reporter.rb +96 -11
  43. data/lib/henitai/result.rb +49 -16
  44. data/lib/henitai/runner.rb +96 -30
  45. data/lib/henitai/scenario_execution_result.rb +16 -3
  46. data/lib/henitai/slot_scheduler/draining.rb +140 -0
  47. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  48. data/lib/henitai/slot_scheduler.rb +214 -0
  49. data/lib/henitai/static_filter.rb +10 -3
  50. data/lib/henitai/survivor_activation_cache.rb +81 -0
  51. data/lib/henitai/survivor_loader.rb +140 -0
  52. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  53. data/lib/henitai/survivor_selector.rb +36 -0
  54. data/lib/henitai/survivor_test_filter.rb +72 -0
  55. data/lib/henitai/unparse_helper.rb +5 -2
  56. data/lib/henitai/version.rb +1 -1
  57. data/lib/henitai.rb +10 -0
  58. data/sig/configuration_validator.rbs +46 -22
  59. data/sig/henitai.rbs +329 -53
  60. metadata +46 -2
@@ -23,31 +23,30 @@ module Henitai
23
23
  end
24
24
  Warning.singleton_class.prepend(ConstantRedefinitionFilter)
25
25
 
26
- SERIALIZER_METHODS = {
27
- arg: :argument_parameter_fragment,
28
- optarg: :optional_parameter_fragment,
29
- restarg: :rest_parameter_fragment,
30
- kwarg: :keyword_parameter_fragment,
31
- kwoptarg: :optional_keyword_parameter_fragment,
32
- kwrestarg: :keyword_rest_parameter_fragment,
33
- blockarg: :block_parameter_fragment,
34
- forward_arg: :forward_parameter_fragment
35
- }.freeze
36
-
37
26
  def self.activate!(mutant)
38
27
  new.activate!(mutant)
39
28
  end
40
29
 
30
+ # Returns the +define_method+ source string for +mutant+ without
31
+ # actually evaluating it. Used to pre-compute activation recipes.
32
+ # Returns nil if the source cannot be computed (e.g. unsupported AST node).
33
+ def self.activation_source_for(mutant)
34
+ new.send(:method_source, mutant)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
41
39
  def activate!(mutant)
42
40
  subject = mutant.subject
43
41
  raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
44
42
 
43
+ source = mutant.precomputed_activation_source || method_source(mutant)
45
44
  target = target_for(subject)
46
45
  Henitai::WarningSilencer.silence do
47
- target.class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
46
+ target.class_eval(source, __FILE__, __LINE__ + 1)
48
47
  nil
49
48
  end
50
- rescue Unparser::UnsupportedNodeError
49
+ rescue Unparser::UnsupportedNodeError, SyntaxError
51
50
  :compile_error
52
51
  end
53
52
 
@@ -113,78 +112,13 @@ module Henitai
113
112
  end
114
113
 
115
114
  def parameter_source(mutant)
116
- args_node = method_arguments(mutant.subject.ast_node)
117
- return "" unless args_node
118
- return forward_parameter_fragment(nil) if args_node.type == :forward_args
119
-
120
- args_node.children.filter_map do |argument|
121
- parameter_fragment(argument)
122
- end.join(", ")
123
- end
124
-
125
- def method_arguments(subject_node)
126
- case subject_node&.type
127
- when :def
128
- subject_node.children[1]
129
- when :defs
130
- subject_node.children[2]
131
- when :block
132
- block_arguments(subject_node)
133
- end
134
- end
135
-
136
- def parameter_fragment(argument)
137
- method_name = SERIALIZER_METHODS[argument&.type]
138
- return unless method_name
139
-
140
- send(method_name, argument)
141
- end
142
-
143
- def argument_parameter_fragment(argument)
144
- argument.children[0].to_s
145
- end
146
-
147
- def optional_parameter_fragment(argument)
148
- "#{argument.children[0]} = #{compile_safe_unparse(argument.children[1])}"
149
- end
150
-
151
- def rest_parameter_fragment(argument)
152
- prefixed_parameter(argument, "*")
153
- end
154
-
155
- def keyword_parameter_fragment(argument)
156
- "#{argument.children[0]}:"
157
- end
158
-
159
- def optional_keyword_parameter_fragment(argument)
160
- "#{argument.children[0]}: #{compile_safe_unparse(argument.children[1])}"
161
- end
162
-
163
- def keyword_rest_parameter_fragment(argument)
164
- prefixed_parameter(argument, "**")
165
- end
166
-
167
- def block_parameter_fragment(argument)
168
- "&#{argument.children[0]}"
169
- end
170
-
171
- def forward_parameter_fragment(_argument)
172
- "*args, **kwargs, &block"
173
- end
174
-
175
- def prefixed_parameter(argument, prefix)
176
- name = argument.children[0]
177
- name ? "#{prefix}#{name}" : prefix
115
+ ParameterSource.new.build(mutant.subject.ast_node)
178
116
  end
179
117
 
180
118
  def block_body(subject_node)
181
119
  subject_node.children[2]
182
120
  end
183
121
 
184
- def block_arguments(subject_node)
185
- subject_node.children[1]
186
- end
187
-
188
122
  def heredoc_location?(location)
189
123
  location.respond_to?(:heredoc_body) && location.heredoc_body
190
124
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+ require "unparser"
5
+
6
+ module Henitai
7
+ class Mutant
8
+ # Builds the parameter-list fragment of a +define_method+ block from a
9
+ # subject's method-definition AST node.
10
+ #
11
+ # Given the +def+/+defs+/+block+ node for a method, {#build} returns the
12
+ # comma-separated parameter source (e.g. "a, b = 1, *rest, key:, &blk")
13
+ # suitable for splicing into a +define_method(:name) do |...| ...+ template.
14
+ class ParameterSource
15
+ SERIALIZER_METHODS = {
16
+ arg: :argument_parameter_fragment,
17
+ optarg: :optional_parameter_fragment,
18
+ restarg: :rest_parameter_fragment,
19
+ kwarg: :keyword_parameter_fragment,
20
+ kwoptarg: :optional_keyword_parameter_fragment,
21
+ kwrestarg: :keyword_rest_parameter_fragment,
22
+ blockarg: :block_parameter_fragment,
23
+ forward_arg: :forward_parameter_fragment
24
+ }.freeze
25
+
26
+ def build(subject_node)
27
+ args_node = method_arguments(subject_node)
28
+ return "" unless args_node
29
+ return forward_parameter_fragment(nil) if args_node.type == :forward_args
30
+
31
+ args_node.children.filter_map do |argument|
32
+ parameter_fragment(argument)
33
+ end.join(", ")
34
+ end
35
+
36
+ private
37
+
38
+ def method_arguments(subject_node)
39
+ case subject_node&.type
40
+ when :def, :block
41
+ subject_node.children[1]
42
+ when :defs
43
+ subject_node.children[2]
44
+ end
45
+ end
46
+
47
+ def parameter_fragment(argument)
48
+ method_name = SERIALIZER_METHODS[argument&.type]
49
+ return unless method_name
50
+
51
+ send(method_name, argument)
52
+ end
53
+
54
+ def argument_parameter_fragment(argument)
55
+ argument.children[0].to_s
56
+ end
57
+
58
+ def optional_parameter_fragment(argument)
59
+ "#{argument.children[0]} = #{compile_safe_unparse(argument.children[1])}"
60
+ end
61
+
62
+ def rest_parameter_fragment(argument)
63
+ prefixed_parameter(argument, "*")
64
+ end
65
+
66
+ def keyword_parameter_fragment(argument)
67
+ "#{argument.children[0]}:"
68
+ end
69
+
70
+ def optional_keyword_parameter_fragment(argument)
71
+ "#{argument.children[0]}: #{compile_safe_unparse(argument.children[1])}"
72
+ end
73
+
74
+ def keyword_rest_parameter_fragment(argument)
75
+ prefixed_parameter(argument, "**")
76
+ end
77
+
78
+ def block_parameter_fragment(argument)
79
+ "&#{argument.children[0]}"
80
+ end
81
+
82
+ def forward_parameter_fragment(_argument)
83
+ "*args, **kwargs, &block"
84
+ end
85
+
86
+ def prefixed_parameter(argument, prefix)
87
+ name = argument.children[0]
88
+ name ? "#{prefix}#{name}" : prefix
89
+ end
90
+
91
+ def compile_safe_unparse(node)
92
+ Unparser.unparse(node)
93
+ rescue StandardError => e
94
+ raise Unparser::UnsupportedNodeError, e.message
95
+ end
96
+ end
97
+ end
98
+ 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.
@@ -16,6 +17,7 @@ module Henitai
16
17
  # :ignored, :no_coverage
17
18
  class Mutant
18
19
  autoload :Activator, "henitai/mutant/activator"
20
+ autoload :ParameterSource, "henitai/mutant/parameter_source"
19
21
 
20
22
  # Status-Vokabular folgt dem Stryker mutation-testing-report-schema.
21
23
  # :equivalent ist ein Henitai-interner Status (wird im JSON als "Ignored" serialisiert,
@@ -34,7 +36,8 @@ module Henitai
34
36
  ].freeze
35
37
 
36
38
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
37
- :mutation_type, :description, :location
39
+ :mutation_type, :description, :location,
40
+ :precomputed_stable_id, :precomputed_activation_source
38
41
  attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
39
42
 
40
43
  # @param subject [Subject] the subject being mutated
@@ -42,7 +45,9 @@ module Henitai
42
45
  # @param nodes [Hash] AST nodes with :original and :mutated entries
43
46
  # @param description [String] human-readable description of the mutation
44
47
  # @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
45
- def initialize(subject:, operator:, nodes:, description:, location:)
48
+ # rubocop:disable Metrics/ParameterLists
49
+ def initialize(subject:, operator:, nodes:, description:, location:,
50
+ precomputed_stable_id: nil, precomputed_activation_source: nil)
46
51
  @id = SecureRandom.uuid
47
52
  @subject = subject
48
53
  @operator = operator
@@ -50,12 +55,19 @@ module Henitai
50
55
  @mutated_node = nodes.fetch(:mutated)
51
56
  @description = description
52
57
  @location = location
58
+ @precomputed_stable_id = precomputed_stable_id
59
+ @precomputed_activation_source = precomputed_activation_source
53
60
  @status = :pending
54
61
  @killing_test = nil
55
62
  @duration = nil
56
63
  @covered_by = nil
57
64
  @tests_completed = nil
58
65
  end
66
+ # rubocop:enable Metrics/ParameterLists
67
+
68
+ def stable_id
69
+ @stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
70
+ end
59
71
 
60
72
  def killed? = @status == :killed
61
73
  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
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class MutantHistoryStore
5
+ # SQL statements used by {MutantHistoryStore} to create the schema and
6
+ # persist run/mutant rows in the SQLite history database.
7
+ module Sql
8
+ RUNS_TABLE = <<~SQL
9
+ CREATE TABLE IF NOT EXISTS runs (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ version TEXT NOT NULL,
12
+ recorded_at TEXT NOT NULL,
13
+ mutation_score REAL,
14
+ mutation_score_indicator REAL,
15
+ equivalence_uncertainty TEXT,
16
+ total_mutants INTEGER NOT NULL,
17
+ killed_mutants INTEGER NOT NULL,
18
+ survived_mutants INTEGER NOT NULL,
19
+ timeout_mutants INTEGER NOT NULL,
20
+ equivalent_mutants INTEGER NOT NULL
21
+ );
22
+ SQL
23
+
24
+ MUTANTS_TABLE = <<~SQL
25
+ CREATE TABLE IF NOT EXISTS mutants (
26
+ mutant_id TEXT PRIMARY KEY,
27
+ first_seen_version TEXT NOT NULL,
28
+ first_seen_at TEXT NOT NULL,
29
+ last_seen_version TEXT NOT NULL,
30
+ last_seen_at TEXT NOT NULL,
31
+ current_status TEXT NOT NULL,
32
+ status_history TEXT NOT NULL,
33
+ days_alive INTEGER NOT NULL
34
+ );
35
+ SQL
36
+
37
+ INSERT_RUN = <<~SQL
38
+ INSERT INTO runs (
39
+ version,
40
+ recorded_at,
41
+ mutation_score,
42
+ mutation_score_indicator,
43
+ equivalence_uncertainty,
44
+ total_mutants,
45
+ killed_mutants,
46
+ survived_mutants,
47
+ timeout_mutants,
48
+ equivalent_mutants
49
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
50
+ SQL
51
+
52
+ UPSERT_MUTANT = <<~SQL
53
+ INSERT INTO mutants (
54
+ mutant_id,
55
+ first_seen_version,
56
+ first_seen_at,
57
+ last_seen_version,
58
+ last_seen_at,
59
+ current_status,
60
+ status_history,
61
+ days_alive
62
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
63
+ ON CONFLICT(mutant_id) DO UPDATE SET
64
+ last_seen_version = excluded.last_seen_version,
65
+ last_seen_at = excluded.last_seen_at,
66
+ current_status = excluded.current_status,
67
+ status_history = excluded.status_history,
68
+ days_alive = excluded.days_alive
69
+ SQL
70
+ end
71
+ end
72
+ end
@@ -1,80 +1,16 @@
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"
9
+ require_relative "mutant_history_store/sql"
10
10
 
11
11
  module Henitai
12
12
  # Persists mutant outcomes across runs in a lightweight SQLite database.
13
- # rubocop:disable Metrics/ClassLength
14
13
  class MutantHistoryStore
15
- RUNS_TABLE_SQL = <<~SQL
16
- CREATE TABLE IF NOT EXISTS runs (
17
- id INTEGER PRIMARY KEY AUTOINCREMENT,
18
- version TEXT NOT NULL,
19
- recorded_at TEXT NOT NULL,
20
- mutation_score REAL,
21
- mutation_score_indicator REAL,
22
- equivalence_uncertainty TEXT,
23
- total_mutants INTEGER NOT NULL,
24
- killed_mutants INTEGER NOT NULL,
25
- survived_mutants INTEGER NOT NULL,
26
- timeout_mutants INTEGER NOT NULL,
27
- equivalent_mutants INTEGER NOT NULL
28
- );
29
- SQL
30
-
31
- MUTANTS_TABLE_SQL = <<~SQL
32
- CREATE TABLE IF NOT EXISTS mutants (
33
- mutant_id TEXT PRIMARY KEY,
34
- first_seen_version TEXT NOT NULL,
35
- first_seen_at TEXT NOT NULL,
36
- last_seen_version TEXT NOT NULL,
37
- last_seen_at TEXT NOT NULL,
38
- current_status TEXT NOT NULL,
39
- status_history TEXT NOT NULL,
40
- days_alive INTEGER NOT NULL
41
- );
42
- SQL
43
-
44
- INSERT_RUN_SQL = <<~SQL
45
- INSERT INTO runs (
46
- version,
47
- recorded_at,
48
- mutation_score,
49
- mutation_score_indicator,
50
- equivalence_uncertainty,
51
- total_mutants,
52
- killed_mutants,
53
- survived_mutants,
54
- timeout_mutants,
55
- equivalent_mutants
56
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
57
- SQL
58
-
59
- UPSERT_MUTANT_SQL = <<~SQL
60
- INSERT INTO mutants (
61
- mutant_id,
62
- first_seen_version,
63
- first_seen_at,
64
- last_seen_version,
65
- last_seen_at,
66
- current_status,
67
- status_history,
68
- days_alive
69
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
70
- ON CONFLICT(mutant_id) DO UPDATE SET
71
- last_seen_version = excluded.last_seen_version,
72
- last_seen_at = excluded.last_seen_at,
73
- current_status = excluded.current_status,
74
- status_history = excluded.status_history,
75
- days_alive = excluded.days_alive
76
- SQL
77
-
78
14
  def initialize(path:)
79
15
  @path = path
80
16
  end
@@ -87,7 +23,7 @@ module Henitai
87
23
  with_database do |db|
88
24
  ensure_schema(db)
89
25
  db.transaction do
90
- insert_run(db, result, version, recorded_at)
26
+ insert_run(db, result, version, recorded_at) unless partial_rerun?(result)
91
27
  Array(result.mutants).each do |mutant|
92
28
  upsert_mutant(db, mutant, version, recorded_at)
93
29
  end
@@ -108,6 +44,10 @@ module Henitai
108
44
 
109
45
  private
110
46
 
47
+ def partial_rerun?(result)
48
+ result.respond_to?(:partial_rerun?) && result.partial_rerun?
49
+ end
50
+
111
51
  def with_database
112
52
  db = SQLite3::Database.new(path)
113
53
  db.results_as_hash = true
@@ -117,17 +57,17 @@ module Henitai
117
57
  end
118
58
 
119
59
  def ensure_schema(db)
120
- db.execute_batch(RUNS_TABLE_SQL)
121
- db.execute_batch(MUTANTS_TABLE_SQL)
60
+ db.execute_batch(Sql::RUNS_TABLE)
61
+ db.execute_batch(Sql::MUTANTS_TABLE)
122
62
  end
123
63
 
124
64
  def insert_run(db, result, version, recorded_at)
125
- db.execute(INSERT_RUN_SQL, insert_run_bindings(result, version, recorded_at))
65
+ db.execute(Sql::INSERT_RUN, insert_run_bindings(result, version, recorded_at))
126
66
  end
127
67
 
128
68
  def upsert_mutant(db, mutant, version, recorded_at)
129
69
  db.execute(
130
- UPSERT_MUTANT_SQL,
70
+ Sql::UPSERT_MUTANT,
131
71
  upsert_mutant_bindings(mutant_history_data(db, mutant, version, recorded_at))
132
72
  )
133
73
  end
@@ -140,25 +80,7 @@ module Henitai
140
80
  end
141
81
 
142
82
  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
83
+ MutantIdentity.stable_id(mutant)
162
84
  end
163
85
 
164
86
  def mutation_history_entry(mutant, version, recorded_at)
@@ -276,4 +198,3 @@ module Henitai
276
198
  end
277
199
  end
278
200
  end
279
- # rubocop:enable Metrics/ClassLength
@@ -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
@@ -53,7 +53,9 @@ module Henitai
53
53
 
54
54
  def current_snapshot
55
55
  Coverage.peek_result
56
- rescue StandardError
56
+ rescue RuntimeError
57
+ # Raised when coverage measurement is not running; caller warns and skips.
58
+ # Other errors are genuine bugs and must surface.
57
59
  nil
58
60
  end
59
61