henitai 0.3.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49d5bc19a4c6d8335797de16d71488d9ffdbb5b85c03b541da1f99a5ba39a009
4
- data.tar.gz: bb6490909c3c87cbe815ed960b649e23d60850ee41bb019097f6a4772de777ab
3
+ metadata.gz: 425e1946a343ce94a9172939a8ad5c5d49e01fe86fbf2b01326b2f0be068df85
4
+ data.tar.gz: 85867b98b26cb3710d0c1e56bd4b37b5ba78f08f47ad8378dc76ae07ce1dfc9a
5
5
  SHA512:
6
- metadata.gz: 10d2c66aa8f8ca9ecd64a482d4bc73880f1598e3174a9df220201874333eab2d846903560dec5270fed4291b401a58f7a41b0e28fcafd5918fbd631bfb77e70b
7
- data.tar.gz: d2c3859f3c70d28032a7a18b859df7713d6e39ca265004c8be86fe0d15ec450aea2e0bf2a60b147e6d2098c6c1f436829bdef87e6ff68353782790c9419465d6
6
+ metadata.gz: f0de6ea6a0a5a830a917f1097ac243ce43c6b8bf3bcc44ac1967133288770746d9c4df8fdee6609081613bfa174be5169e13143a22f1d3d22e8f74fd0cc008d7
7
+ data.tar.gz: 37eedd8f351dbd8a9094c668ce7c56560186936f0953b098f740b6cee1654971d1b5402efe2fd0d5650442b701af7fedaea257ec9f7827575f3f04c052c2593a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-07-13
11
+
12
+ ### Fixed
13
+ - Per-mutant log capture no longer crashes with
14
+ `Encoding::UndefinedConversionError` when the host app sets
15
+ `Encoding.default_internal` (as Rails test environments do) and a child
16
+ process prints multibyte output: the capped log streams are opened in
17
+ binary mode, so pipe chunks are written byte-for-byte. Also keeps a
18
+ multibyte character split across drain chunks (or truncated at the cap)
19
+ from corrupting the file.
20
+
21
+ ### Changed
22
+ - `--since REF` now includes the working tree: tracked files with
23
+ uncommitted changes and untracked files count as changed, matching what
24
+ actually gets tested. Previously only committed changes in `REF..HEAD`
25
+ were considered, so pre-commit runs silently selected nothing.
26
+ - `--since REF` is test-aware: a changed test file selects the source files
27
+ it covers, using the per-test coverage map from the previous run
28
+ (`henitai_per_test.json`). Editing only a test now re-tests the subjects
29
+ that test can kill. First run without a map: no expansion.
30
+ - An empty `--since` run now says why: the terminal summary appends
31
+ `No mutants: no configured source files changed since REF.` instead of
32
+ printing an unexplained all-zero table. `Result` gained a `since`
33
+ attribute to carry the scope.
34
+
10
35
  ## [0.3.0] - 2026-07-13
11
36
 
12
37
  ### Added
@@ -380,7 +405,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
380
405
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
381
406
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
382
407
 
383
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.3.0...HEAD
408
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.3.1...HEAD
409
+ [0.3.1]: https://github.com/martinotten/henitai/compare/v0.3.0...v0.3.1
384
410
  [0.3.0]: https://github.com/martinotten/henitai/compare/v0.2.1...v0.3.0
385
411
  [0.2.1]: https://github.com/martinotten/henitai/compare/v0.2.0...v0.2.1
386
412
  [0.2.0]: https://github.com/martinotten/henitai/compare/v0.1.10...v0.2.0
data/README.md CHANGED
@@ -51,7 +51,8 @@ gem install henitai
51
51
  # Run mutation testing on the entire project
52
52
  bundle exec henitai run
53
53
 
54
- # Run only on subjects changed since main (CI-friendly)
54
+ # Run only on subjects changed since main (CI-friendly). Includes uncommitted
55
+ # working-tree changes; changed test files select the sources they cover.
55
56
  bundle exec henitai run --since origin/main
56
57
 
57
58
  # Run on a specific subject pattern
@@ -62,8 +62,11 @@ module Henitai
62
62
 
63
63
  # One capture channel: a pipe whose read end is drained (capped) into the
64
64
  # log file by a dedicated thread. Returns the pieces close needs.
65
+ # Binary mode: readpartial yields ASCII-8BIT chunks, and a text-mode file
66
+ # would transcode them (raising Encoding::UndefinedConversionError on
67
+ # multibyte output when Encoding.default_internal is set, as Rails does).
65
68
  def open_capped_stream(path, cap)
66
- file = File.new(path, "w")
69
+ file = File.new(path, "wb")
67
70
  file.sync = true
68
71
  reader, writer = IO.pipe
69
72
  thread = Thread.new { drain_capped(reader, file, cap) }
@@ -28,6 +28,21 @@ module Henitai
28
28
  map.keys.select { |test| covers?(test, mutant) }.sort
29
29
  end
30
30
 
31
+ # The source files the given test file's recorded coverage reaches.
32
+ # Test keys are matched path-insensitively (relative or absolute form);
33
+ # returned source paths are absolute, as recorded.
34
+ #
35
+ # @return [Array<String>] sorted; empty when the test is unknown or the
36
+ # map is unavailable.
37
+ def source_files_covered_by(test_file)
38
+ wanted = File.expand_path(test_file.to_s)
39
+ map.select { |test, _| File.expand_path(test) == wanted }
40
+ .values
41
+ .flat_map(&:keys)
42
+ .uniq
43
+ .sort
44
+ end
45
+
31
46
  # True when the given test file's recorded coverage intersects the
32
47
  # mutant's current line range.
33
48
  def covers?(test, mutant)
@@ -137,10 +137,20 @@ module Henitai
137
137
  format_row("No coverage", count_status(result, :no_coverage)),
138
138
  format_row("Duration", format_duration(result.duration)),
139
139
  reused_verdicts_line(result),
140
- executed_only_score_line(result)
140
+ executed_only_score_line(result),
141
+ empty_since_scope_line(result)
141
142
  ].compact
142
143
  end
143
144
 
145
+ # A zero-mutant summary with every counter at 0 and n/a scores is
146
+ # baffling without its cause; name it when the run was scoped by --since.
147
+ def empty_since_scope_line(result)
148
+ return nil unless result.mutants.empty?
149
+ return nil unless result.respond_to?(:since) && result.since
150
+
151
+ "No mutants: no configured source files changed since #{result.since}."
152
+ end
153
+
144
154
  def reused_verdicts_line(result)
145
155
  reused = reused_mutants(result)
146
156
  return nil if reused.empty?
@@ -16,7 +16,7 @@ module Henitai
16
16
  DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
17
17
 
18
18
  attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
19
- :session_id, :git_sha
19
+ :session_id, :git_sha, :since
20
20
 
21
21
  # @param source_provider [#call] maps a file path to its source string.
22
22
  # Injected so the domain object performs no disk IO; the caller (which
@@ -32,7 +32,8 @@ module Henitai
32
32
  def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
33
33
  partial_rerun: false, survivor_stats: nil,
34
34
  session_id: SecureRandom.uuid, git_sha: nil,
35
- source_provider: ->(_file) { "" }, authoritative: true)
35
+ source_provider: ->(_file) { "" }, authoritative: true,
36
+ since: nil)
36
37
  @mutants = mutants
37
38
  @started_at = started_at
38
39
  @finished_at = finished_at
@@ -43,6 +44,7 @@ module Henitai
43
44
  @git_sha = git_sha
44
45
  @source_provider = source_provider
45
46
  @authoritative = authoritative
47
+ @since = since
46
48
  end
47
49
  # rubocop:enable Metrics/ParameterLists
48
50
 
@@ -168,7 +168,8 @@ module Henitai
168
168
  survivor_stats: survivor_strategy.survivor_stats,
169
169
  git_sha: safe_head_sha,
170
170
  source_provider: source_provider,
171
- authoritative: full_run?
171
+ authoritative: full_run?,
172
+ since: @since
172
173
  )
173
174
  end
174
175
 
@@ -267,12 +268,28 @@ module Henitai
267
268
  def filter_changed(files)
268
269
  return files unless @since
269
270
 
270
- changed_file_set = git_diff_analyzer
271
- .changed_files(from: @since, to: "HEAD")
272
- .map { |path| normalize_path(path) }
271
+ changed_file_set = changed_paths_since.map { |path| normalize_path(path) }
272
+ changed_file_set += covered_sources_for_changed_tests(changed_file_set)
273
273
  files.select { |path| changed_file_set.include?(normalize_path(path)) }
274
274
  end
275
275
 
276
+ # Committed changes since the ref plus the current working tree (tracked
277
+ # dirty and untracked files): the working tree is what gets tested, so it
278
+ # is always part of "changed since REF".
279
+ def changed_paths_since
280
+ git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
281
+ git_diff_analyzer.working_tree_changed_files
282
+ end
283
+
284
+ # Changed test files select the source files they cover, so an edited
285
+ # test re-tests the subjects it can kill. Uses the per-test map from the
286
+ # previous run — Gate 1 runs before this run's bootstrap finishes.
287
+ def covered_sources_for_changed_tests(changed_paths)
288
+ changed_paths
289
+ .flat_map { |path| per_test_coverage.source_files_covered_by(path) }
290
+ .map { |path| normalize_path(path) }
291
+ end
292
+
276
293
  def pattern_subjects
277
294
  Array(@subjects)
278
295
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/sig/henitai.rbs CHANGED
@@ -718,6 +718,7 @@ module Henitai
718
718
  def initialize: (reports_dir: String, ?coverage_report_reader: CoverageReportReader) -> void
719
719
  def available?: () -> bool
720
720
  def tests_covering: (untyped) -> Array[String]
721
+ def source_files_covered_by: (untyped) -> Array[String]
721
722
  def covers?: (String, untyped) -> bool
722
723
 
723
724
  private
@@ -852,6 +853,7 @@ module Henitai
852
853
  def reused_verdicts_line: (Result) -> String?
853
854
  def reused_mutants: (Result) -> Array[Mutant]
854
855
  def executed_only_score_line: (Result) -> String?
856
+ def empty_since_scope_line: (Result) -> String?
855
857
  def count_status: (Result, Symbol) -> Integer
856
858
  def format_duration: (Float) -> String
857
859
  def format_percent: (Float?) -> String
@@ -977,8 +979,9 @@ module Henitai
977
979
  attr_reader survivor_stats: Hash[Symbol, untyped]?
978
980
  attr_reader session_id: String
979
981
  attr_reader git_sha: String?
982
+ attr_reader since: String?
980
983
 
981
- def initialize: (mutants: Array[Mutant], started_at: Time, finished_at: Time, ?thresholds: Hash[Symbol, Integer]?, ?partial_rerun: bool, ?survivor_stats: Hash[Symbol, untyped]?, ?session_id: String, ?git_sha: String?, ?source_provider: ^(String) -> String, ?authoritative: bool) -> void
984
+ def initialize: (mutants: Array[Mutant], started_at: Time, finished_at: Time, ?thresholds: Hash[Symbol, Integer]?, ?partial_rerun: bool, ?survivor_stats: Hash[Symbol, untyped]?, ?session_id: String, ?git_sha: String?, ?source_provider: ^(String) -> String, ?authoritative: bool, ?since: String?) -> void
982
985
  def partial_rerun?: () -> bool
983
986
  def authoritative?: () -> bool
984
987
  def killed: () -> Integer
@@ -1086,6 +1089,8 @@ module Henitai
1086
1089
  def reject_excluded: (Array[String]) -> Array[String]
1087
1090
  def excluded_source_files: () -> Array[String]
1088
1091
  def filter_changed: (Array[String]) -> Array[String]
1092
+ def changed_paths_since: () -> Array[String]
1093
+ def covered_sources_for_changed_tests: (Array[String]) -> Array[String]
1089
1094
  def pattern_subjects: () -> Array[Subject]
1090
1095
  def unique_subjects: (Array[Subject]) -> Array[Subject]
1091
1096
  def normalize_path: (String) -> 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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Otten
@@ -223,7 +223,7 @@ metadata:
223
223
  changelog_uri: https://github.com/martinotten/henitai/blob/main/CHANGELOG.md
224
224
  documentation_uri: https://github.com/martinotten/henitai/blob/main/README.md
225
225
  homepage_uri: https://github.com/martinotten/henitai
226
- source_code_uri: https://github.com/martinotten/henitai/tree/v0.3.0
226
+ source_code_uri: https://github.com/martinotten/henitai/tree/v0.3.1
227
227
  rubygems_mfa_required: 'true'
228
228
  rdoc_options: []
229
229
  require_paths: