henitai 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb30ced7751d8a102dbfe3a5eebdb33235997723d6b2f9c7f6e5c43a02eb09b6
4
- data.tar.gz: 6386ce164d48f3527f5fa042595c4218e8b508b5d7a64a255a37e74b4696b867
3
+ metadata.gz: d11d8d5361258fac7bddc006b8fd8485d1d085c3eaf294afc5dc97c6eed9ee7c
4
+ data.tar.gz: c3620c79cd2b1e59ec43abb143775e0b5edcbc2ed89b3384caa39703e7b9759a
5
5
  SHA512:
6
- metadata.gz: 5c6463df3bd0184d655aedf48110635fa7706e161ef41c2a1305a738d2499713a4fd522e7a540f87900fac181cc10526fef4eadb541951d079a877c70762f4de
7
- data.tar.gz: 16e16613e3c3055105685f73a0832f9aa4ce401f254710af391ef6d5c1983d3a7ecaec44ae7b5c1976bfa8a8714a7a8e1951050aa49c034d1afb817e9812817d
6
+ metadata.gz: eed2ea62f262d95b79bae130a0d19437749c07b8873d532bd029755a72d9600335131167da004fe01dcb9d83427fb6155de1d841df4ea8dac0e2b4651de33d26
7
+ data.tar.gz: a4709f5c4804af79ea9599a62dfce714487c6d947bc8f85459cebebe206c4f0f7df965bff8c0aedacca227dceb67b32876733e9bfb450dc96357e2ef879f07c4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.2] - 2026-04-07
11
+
12
+ ### Fixed
13
+ - `Henitai::Mutant::Activator` now rewrites heredoc-backed method bodies from
14
+ source slices instead of unparsing the whole body, eliminating timeouts on
15
+ HTML reporter mutants
16
+ - `henitai run -v` now stops before the run pipeline starts
17
+
10
18
  ## [0.1.1] - 2026-04-03
11
19
 
12
20
  ### Added
@@ -34,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
34
42
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
35
43
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
36
44
 
37
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.1...HEAD
45
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.2...HEAD
46
+ [0.1.2]: https://github.com/martinotten/henitai/compare/v0.1.1...v0.1.2
38
47
  [0.1.1]: https://github.com/martinotten/henitai/compare/v0.1.0...v0.1.1
39
48
  [0.1.0]: https://github.com/martinotten/henitai/releases/tag/v0.1.0
@@ -9,7 +9,6 @@ module Henitai
9
9
 
10
10
  def ensure!(source_files:, config:, integration:)
11
11
  return if source_files.empty?
12
- return if coverage_available?(source_files, config)
13
12
 
14
13
  bootstrap_coverage(integration, config)
15
14
  return if coverage_available?(source_files, config)
@@ -10,8 +10,6 @@ module Henitai
10
10
  REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
11
11
  REPORT_FILE_NAME = "henitai_per_test.json"
12
12
 
13
- RSpec::Core::Formatters.register self, :example_finished, :dump_summary
14
-
15
13
  def initialize(_output)
16
14
  @coverage_by_test = Hash.new do |hash, test_file|
17
15
  hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
@@ -91,7 +89,7 @@ module Henitai
91
89
  def line_counts_for(file_coverage)
92
90
  case file_coverage
93
91
  when Hash
94
- Array(file_coverage["lines"])
92
+ Array(file_coverage[:lines] || file_coverage["lines"])
95
93
  else
96
94
  Array(file_coverage)
97
95
  end
@@ -105,7 +103,9 @@ module Henitai
105
103
 
106
104
  def serializable_report
107
105
  @coverage_by_test.transform_values do |source_map|
108
- source_map.transform_values { |lines| lines.uniq.sort }
106
+ source_map.to_h do |source_file, lines|
107
+ [File.expand_path(source_file), lines.uniq.sort]
108
+ end
109
109
  end
110
110
  end
111
111
  end
@@ -56,8 +56,8 @@ module Henitai
56
56
 
57
57
  def build_child_output_files(log_paths)
58
58
  {
59
- original_stdout: $stdout.dup,
60
- original_stderr: $stderr.dup,
59
+ original_stdout: stdout_stream.dup,
60
+ original_stderr: stderr_stream.dup,
61
61
  stdout_file: File.new(log_paths[:stdout_path], "w"),
62
62
  stderr_file: File.new(log_paths[:stderr_path], "w")
63
63
  }
@@ -69,13 +69,17 @@ module Henitai
69
69
  end
70
70
 
71
71
  def redirect_child_output(output_files)
72
- $stdout.reopen(output_files[:stdout_file])
73
- $stderr.reopen(output_files[:stderr_file])
72
+ reopen_child_output_stream(stdout_stream, output_files[:stdout_file])
73
+ reopen_child_output_stream(stderr_stream, output_files[:stderr_file])
74
+ $stdout = stdout_stream
75
+ $stderr = stderr_stream
74
76
  end
75
77
 
76
78
  def restore_child_output(output_files)
77
- reopen_child_output_stream($stdout, output_files[:original_stdout])
78
- reopen_child_output_stream($stderr, output_files[:original_stderr])
79
+ reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
80
+ reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
81
+ $stdout = output_files[:original_stdout]
82
+ $stderr = output_files[:original_stderr]
79
83
  end
80
84
 
81
85
  def reopen_child_output_stream(stream, original_stream)
@@ -94,6 +98,14 @@ module Henitai
94
98
  reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
95
99
  File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
96
100
  end
101
+
102
+ def stdout_stream
103
+ @stdout_stream ||= IO.for_fd(1)
104
+ end
105
+
106
+ def stderr_stream
107
+ @stderr_stream ||= IO.for_fd(2)
108
+ end
97
109
  end
98
110
 
99
111
  # Integration adapter for RSpec.
@@ -105,7 +117,12 @@ module Henitai
105
117
  def self.for(name)
106
118
  const_get(name.capitalize)
107
119
  rescue NameError
108
- raise ArgumentError, "Unknown integration: #{name}. Available: rspec"
120
+ available = constants.filter_map do |constant_name|
121
+ integration = const_get(constant_name)
122
+ constant_name.to_s.downcase if integration.is_a?(Class) && integration < Base
123
+ end.sort.join(", ")
124
+
125
+ raise ArgumentError, "Unknown integration: #{name}. Available: #{available}"
109
126
  end
110
127
 
111
128
  # Base class for all integrations.
@@ -130,6 +147,12 @@ module Henitai
130
147
  def run_mutant(mutant:, test_files:, timeout:)
131
148
  raise NotImplementedError
132
149
  end
150
+
151
+ private
152
+
153
+ def pause(seconds)
154
+ sleep(seconds)
155
+ end
133
156
  end
134
157
 
135
158
  # RSpec integration adapter.
@@ -185,6 +208,7 @@ module Henitai
185
208
  private
186
209
 
187
210
  def run_in_child(mutant:, test_files:, log_paths:)
211
+ Thread.report_on_exception = false
188
212
  scenario_log_support.with_coverage_dir(mutant.id) do
189
213
  scenario_log_support.capture_child_output(log_paths) do
190
214
  return 2 if Mutant::Activator.activate!(mutant) == :compile_error
@@ -231,11 +255,7 @@ module Henitai
231
255
  end
232
256
 
233
257
  def rspec_options
234
- ["--require", "henitai/coverage_formatter"]
235
- end
236
-
237
- def pause(seconds)
238
- sleep(seconds)
258
+ ["--require", "henitai/rspec_coverage_formatter"]
239
259
  end
240
260
 
241
261
  def scenario_log_support
@@ -6,6 +6,9 @@
6
6
  # Must be required before any application code is loaded so that Coverage
7
7
  # tracking is active from the first line.
8
8
 
9
+ require "coverage"
10
+ Coverage.start(lines: true, branches: true, methods: true)
11
+
9
12
  require "simplecov"
10
13
 
11
14
  SimpleCov.coverage_dir(ENV.fetch("HENITAI_COVERAGE_DIR", "coverage"))
@@ -7,6 +7,22 @@ module Henitai
7
7
  class Mutant
8
8
  # Activates a mutant inside the forked child process.
9
9
  class Activator
10
+ # Filters "already initialized constant" C-level warnings that fire when
11
+ # a source file is loaded into a process that already has the constant
12
+ # defined via require. Uses a thread-local flag so the filter is active
13
+ # only during load_source_file, leaving all other warnings untouched.
14
+ module ConstantRedefinitionFilter
15
+ PATTERN = /already initialized constant|previous definition of/
16
+ private_constant :PATTERN
17
+
18
+ def warn(msg, **kwargs)
19
+ return if Thread.current[:henitai_filter_const_warnings] && PATTERN.match?(msg.to_s)
20
+
21
+ super
22
+ end
23
+ end
24
+ Warning.singleton_class.prepend(ConstantRedefinitionFilter)
25
+
10
26
  SERIALIZER_METHODS = {
11
27
  arg: :argument_parameter_fragment,
12
28
  optarg: :optional_parameter_fragment,
@@ -26,8 +42,9 @@ module Henitai
26
42
  subject = mutant.subject
27
43
  raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
28
44
 
45
+ target = target_for(subject)
29
46
  Henitai::WarningSilencer.silence do
30
- target_for(subject).class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
47
+ target.class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
31
48
  nil
32
49
  end
33
50
  rescue Unparser::UnsupportedNodeError
@@ -57,44 +74,28 @@ module Henitai
57
74
  subject_node = mutant.subject.ast_node
58
75
  return compile_safe_unparse(mutant.mutated_node) unless subject_node
59
76
 
60
- mutated_subject = replace_node(
61
- subject_node,
62
- mutant.original_node,
63
- mutant.mutated_node
64
- )
65
- body = method_body(mutated_subject) || Parser::AST::Node.new(:nil, [])
66
- compile_safe_unparse(body)
67
- end
68
-
69
- def replace_node(node, original_node, mutated_node)
70
- return mutated_node if same_node?(node, original_node)
71
- return node unless node.is_a?(Parser::AST::Node)
77
+ body = method_body(subject_node)
78
+ return compile_safe_unparse(Parser::AST::Node.new(:nil, [])) unless body
72
79
 
73
- updated_children = node.children.map do |child|
74
- replace_child(child, original_node, mutated_node)
75
- end
76
-
77
- return node if updated_children == node.children
78
-
79
- Parser::AST::Node.new(node.type, updated_children)
80
+ body_source_for_mutant(body, mutant)
80
81
  end
81
82
 
82
- def same_node?(left, right)
83
- left_location = node_location_signature(left)
84
- right_location = node_location_signature(right)
85
- return left.equal?(right) unless left_location && right_location
83
+ def body_source_for_mutant(body, mutant)
84
+ original_range = mutant.original_node.location&.expression
85
+ location = body.location
86
+ return source_body(location, body) unless original_range && location
86
87
 
87
- left_location == right_location
88
+ replacement = compile_safe_unparse(mutant.mutated_node)
89
+ body_source_for_location(location, original_range, replacement, body)
88
90
  end
89
91
 
90
- def replace_child(child, original_node, mutated_node)
91
- case child
92
- when Parser::AST::Node
93
- replace_node(child, original_node, mutated_node)
94
- when Array
95
- child.map { |item| replace_child(item, original_node, mutated_node) }
92
+ def body_source_for_location(location, original_range, replacement, body)
93
+ if heredoc_location?(location)
94
+ heredoc_body_source(location, original_range, replacement) ||
95
+ source_body(location, body)
96
96
  else
97
- child
97
+ expression_source(location, original_range, replacement) ||
98
+ source_body(location, body)
98
99
  end
99
100
  end
100
101
 
@@ -184,6 +185,38 @@ module Henitai
184
185
  subject_node.children[1]
185
186
  end
186
187
 
188
+ def heredoc_location?(location)
189
+ location.respond_to?(:heredoc_body) && location.heredoc_body
190
+ end
191
+
192
+ def heredoc_body_source(location, original_range, replacement)
193
+ body_source = replace_source_fragment(
194
+ location.heredoc_body,
195
+ original_range,
196
+ replacement
197
+ )
198
+ return unless body_source
199
+
200
+ "#{location.expression.source}\n#{body_source}#{location.heredoc_end.source}"
201
+ end
202
+
203
+ def source_body(location, body)
204
+ return compile_safe_unparse(body) unless location
205
+
206
+ if heredoc_location?(location)
207
+ "#{location.expression.source}\n#{location.heredoc_body.source}#{location.heredoc_end.source}"
208
+ else
209
+ location.expression.source
210
+ end
211
+ end
212
+
213
+ def expression_source(location, original_range, replacement)
214
+ source_range = location.expression
215
+ return unless source_range
216
+
217
+ replace_source_fragment(source_range, original_range, replacement)
218
+ end
219
+
187
220
  def load_target(subject)
188
221
  Object.const_get(subject.namespace.delete_prefix("::"))
189
222
  rescue NameError
@@ -195,7 +228,10 @@ module Henitai
195
228
  source_file = subject.source_file || source_file_from_ast(subject)
196
229
  return unless source_file && File.file?(source_file)
197
230
 
231
+ Thread.current[:henitai_filter_const_warnings] = true
198
232
  load(source_file)
233
+ ensure
234
+ Thread.current[:henitai_filter_const_warnings] = false
199
235
  end
200
236
 
201
237
  def source_file_from_ast(subject)
@@ -211,17 +247,17 @@ module Henitai
211
247
  expression.source_buffer.name
212
248
  end
213
249
 
214
- def node_location_signature(node)
215
- expression = node&.location&.expression
216
- return unless expression
250
+ def replace_source_fragment(source_range, original_range, replacement)
251
+ source = source_range.source
252
+ start = original_range.begin_pos - source_range.begin_pos
253
+ stop = original_range.end_pos - source_range.begin_pos
254
+ return unless start >= 0 && stop <= source.bytesize && start <= stop
255
+
256
+ prefix = source.byteslice(0, start)
257
+ suffix = source.byteslice(stop, source.bytesize - stop)
258
+ return unless prefix && suffix
217
259
 
218
- [
219
- expression.source_buffer.name,
220
- expression.line,
221
- expression.column,
222
- expression.last_line,
223
- expression.last_column
224
- ]
260
+ prefix + replacement + suffix
225
261
  end
226
262
 
227
263
  def compile_safe_unparse(node)
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "henitai/coverage_formatter"
5
+
6
+ RSpec::Core::Formatters.register(
7
+ Henitai::CoverageFormatter,
8
+ :example_finished,
9
+ :dump_summary
10
+ )
@@ -32,6 +32,7 @@ module Henitai
32
32
  per_test_coverage_report_path = per_test_coverage_report_path(config)
33
33
 
34
34
  coverage_lines = coverage_lines_by_file(coverage_report_path)
35
+ coverage_lines = merge_method_coverage(coverage_lines, coverage_report_path)
35
36
  return coverage_lines unless coverage_lines.empty?
36
37
 
37
38
  coverage_lines_from_test_lines(
@@ -97,9 +98,10 @@ module Henitai
97
98
 
98
99
  def covered?(mutant, coverage_lines)
99
100
  file = normalize_path(mutant.location[:file])
100
- start_line = mutant.location[:start_line]
101
-
102
- Array(coverage_lines[file]).include?(start_line)
101
+ covered = Array(coverage_lines[file])
102
+ (mutant.location[:start_line]..mutant.location[:end_line]).any? do |line|
103
+ covered.include?(line)
104
+ end
103
105
  end
104
106
 
105
107
  def source_for(mutant)
@@ -115,6 +117,40 @@ module Henitai
115
117
  @compiled_ignore_patterns[patterns] ||= patterns.map { |pattern| Regexp.new(pattern) }
116
118
  end
117
119
 
120
+ def merge_method_coverage(coverage_lines, path)
121
+ return coverage_lines unless File.exist?(path)
122
+
123
+ JSON.parse(File.read(path)).each_value do |suite|
124
+ suite.fetch("coverage", {}).each do |file, file_coverage|
125
+ merge_file_method_coverage(coverage_lines, file, file_coverage)
126
+ end
127
+ end
128
+
129
+ coverage_lines.transform_values(&:sort)
130
+ end
131
+
132
+ def merge_file_method_coverage(coverage_lines, file, file_coverage)
133
+ methods = file_coverage["methods"]
134
+ return unless methods.is_a?(Hash)
135
+
136
+ normalized = normalize_path(file)
137
+ methods.each do |key, count|
138
+ next unless count.to_i.positive?
139
+
140
+ range = method_line_range(key)
141
+ next unless range
142
+
143
+ coverage_lines[normalized] = Array(coverage_lines[normalized]) | range.to_a
144
+ end
145
+ end
146
+
147
+ def method_line_range(key)
148
+ m = key.match(/(\d+), \d+, (\d+), \d+\]\z/)
149
+ return unless m
150
+
151
+ (m.captures.first.to_i..m.captures.last.to_i)
152
+ end
153
+
118
154
  def covered_lines(file_coverage)
119
155
  Array(file_coverage["lines"]).each_with_index.filter_map do |count, index|
120
156
  index + 1 if count.to_i.positive?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
data/sig/henitai.rbs CHANGED
@@ -223,6 +223,10 @@ module Henitai
223
223
  def test_files: () -> Array[String]
224
224
  def run_mutant: (mutant: Mutant, test_files: Array[String], timeout: Float) -> ScenarioExecutionResult
225
225
  def run_suite: (Array[String], ?timeout: Float) -> ScenarioExecutionResult
226
+
227
+ private
228
+
229
+ def pause: (Float) -> void
226
230
  end
227
231
 
228
232
  class ScenarioLogSupport
@@ -240,6 +244,8 @@ module Henitai
240
244
  def reopen_child_output_stream: (untyped, untyped) -> void
241
245
  def close_child_output_files: (Hash[Symbol, untyped]) -> void
242
246
  def mutation_coverage_dir: (String) -> String
247
+ def stdout_stream: () -> IO
248
+ def stderr_stream: () -> IO
243
249
  end
244
250
 
245
251
  class Rspec < Base
@@ -265,7 +271,6 @@ module Henitai
265
271
  def combined_log: (String, String) -> String
266
272
  def scenario_log_paths: (String) -> Hash[Symbol, String]
267
273
  def run_tests: (Array[String]) -> Integer
268
- def pause: (Float) -> void
269
274
  def scenario_log_support: () -> ScenarioLogSupport
270
275
  def rspec_options: () -> Array[String]
271
276
  def spec_files: () -> Array[String]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: henitai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Otten
@@ -122,6 +122,7 @@ files:
122
122
  - lib/henitai/parser_current.rb
123
123
  - lib/henitai/reporter.rb
124
124
  - lib/henitai/result.rb
125
+ - lib/henitai/rspec_coverage_formatter.rb
125
126
  - lib/henitai/runner.rb
126
127
  - lib/henitai/sampling_strategy.rb
127
128
  - lib/henitai/scenario_execution_result.rb
@@ -146,7 +147,7 @@ metadata:
146
147
  changelog_uri: https://github.com/martinotten/henitai/blob/main/CHANGELOG.md
147
148
  documentation_uri: https://github.com/martinotten/henitai/blob/main/README.md
148
149
  homepage_uri: https://github.com/martinotten/henitai
149
- source_code_uri: https://github.com/martinotten/henitai/tree/v0.1.1
150
+ source_code_uri: https://github.com/martinotten/henitai/tree/v0.1.2
150
151
  rubygems_mfa_required: 'true'
151
152
  rdoc_options: []
152
153
  require_paths: