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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93624e520ff6014d8b5e69ee8b3ac0939d490697130e4acb996cce369c331b78
4
- data.tar.gz: 02f3116c9e5031df714e08b8d394cd0b90c914c57f1ffe9639540ca4251b4013
3
+ metadata.gz: b2062372801be4391691c6040c2221147a5b14ec346726466d1abe8e916a7670
4
+ data.tar.gz: b51bd86b3f98751b4cc28bf0e443c56158fe6051f754e2ed954dc74940824f29
5
5
  SHA512:
6
- metadata.gz: 8823368e6bdb4fd73ce08253983a63088037a488f8e8a423d36ba495be44f157bb5e1d0a77d3ee1105986eb7c965c670552a489d9b50c3657a8e6935d1774003
7
- data.tar.gz: b49c42e37777ca6519436ab327a58fcc0a9ab012ad6ad9ffee226c9cacfe156612653bb6a3e794cdf9255d1cf84fa36c6b221ef12dfafc0523ddd884a7ff6e50
6
+ metadata.gz: d128d68a96d10a3aeccdd88654cce9ef2ad53d527f196c1a29c1f02416f7d1b045989766872eef92b1832f8a283fdc726fc3b994098b464b22d91897a2993450
7
+ data.tar.gz: 9b13d2a228c4fd266b49db1d102ca796cd71c534458a341565fd2c1788088bd33f22c1c32bd5c9eeaee4f72d6c3658ea431a78eb5631960299736aea9575b627
data/CHANGELOG.md CHANGED
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-04-30
11
+
12
+ ### Added
13
+ - `--survivors-from <path>` flag on `henitai run` for survivor-only reruns:
14
+ re-execute only the mutants that survived a prior full run without re-running
15
+ the whole suite
16
+ - `SurvivorLoader` reads a Stryker-compatible JSON report and extracts survivor
17
+ IDs, per-survivor coverage maps (`coveredBy`), and the anchoring git SHA
18
+ - `SurvivorSelector` filters the current mutant set to the survivor subset; emits
19
+ a drift warning when more than 50% of loaded survivors are unmatched (source
20
+ changed significantly since the prior run)
21
+ - `SurvivorTestFilter` skips survivors whose covering tests are unchanged since
22
+ the prior report's git SHA, marking them `:survived` immediately without
23
+ execution — same safety logic as StrykerJS incremental mode
24
+ - `MutantIdentity` module: stable SHA256 identity computed from expression,
25
+ operator, description, location, and mutation signature; shared by
26
+ `MutantHistoryStore` and `Mutant#stable_id`
27
+ - `Mutant#stable_id` exposes the stable identity; emitted as `stableId` in the
28
+ JSON report so survivor reports remain useful across commits
29
+ - `Result` carries `session_id` (UUID) and optional `git_sha` (HEAD SHA at
30
+ report time); both emitted in the Stryker-compatible JSON as `sessionId` /
31
+ `gitSha`
32
+ - `Reporter::Json` writes an immutable per-session snapshot alongside the
33
+ canonical report at `reports/sessions/<session_id>/mutation-report.json`,
34
+ giving `--survivors-from` a stable reference path across runs
35
+ - `GitDiffAnalyzer#head_sha` — returns the current HEAD SHA; `nil` when git is
36
+ unavailable (conservative fallback: all survivors are executed)
37
+ - `ProcessWorkerRunner` — flat process-slot scheduler for parallel mutant
38
+ execution: each slot owns one OS process, slots are refilled as children
39
+ finish, no thread per child
40
+ - Interrupt handling, spawn failure isolation, and in-slot retry added to
41
+ `ProcessWorkerRunner` (PR 6)
42
+ - Timeout precision and two-phase process-group cleanup in
43
+ `ProcessWorkerRunner` (PR 5)
44
+ - x86_64 platform added to gem platform list
45
+
46
+ ### Changed
47
+ - JSON mutation report vendor extension now always includes `sessionId`
48
+ (and `gitSha` when available) to support survivor-only reruns
49
+ - Terminal reporter labels partial reruns as "Partial survivor rerun" and
50
+ shows matched / unmatched / skipped-by-diff counts
51
+ - History store skips `runs` row insertion for partial reruns to avoid
52
+ distorting trend analytics; per-mutant `current_status` upsert still runs
53
+ - CLI exits 0 for partial reruns with a printed warning; threshold comparison
54
+ is skipped (applying a partial score to a CI gate is misleading)
55
+ - `StaticFilter` merges per-test coverage into standard coverage so
56
+ `coveredBy` data is available to both RSpec and Minitest survivor reports
57
+ - `.henitai.yml` default: operators set to `light`, timeout lowered to 10 s,
58
+ `max_flaky_retries: 3` added
59
+
60
+ ### Fixed
61
+ - Minitest autorun hook suppressed in mutation child processes to prevent
62
+ spurious re-runs of the full suite inside each fork
63
+ - Coverage bootstrap RSpec subprocess ARGV leakage resolved — child processes
64
+ no longer inherit the parent's `--format` / file arguments
65
+ - Survivor rerun state preserved across RSpec execution (was reset on each
66
+ subprocess boot)
67
+ - `--survivors-from` now respects dirty worktrees: coverage and source file
68
+ state are read from the working tree, not the index
69
+ - Recipe fast path skipped when source files changed since the cached run,
70
+ preventing stale cache hits after edits
71
+
72
+ ### Performance
73
+ - File discovery cached in the integration layer; repeated calls within one
74
+ run no longer re-scan the filesystem
75
+ - Polling sleep removed from the scheduler hot loop; slots are refilled
76
+ event-driven on child exit
77
+
78
+ ## [0.1.10] - 2026-04-16
79
+
80
+ ### Fixed
81
+ - RuboCop `RSpec/MultipleExpectations` offenses in `coverage_formatter_spec` and
82
+ `per_test_coverage_collector_spec` resolved by splitting each two-assertion
83
+ example into focused single-expectation examples
84
+
85
+ ## [0.1.9] - 2026-04-16
86
+
87
+ ### Fixed
88
+ - SimpleCov is now suppressed during Minitest mutant child runs: `SimpleCov.start`
89
+ is turned into a no-op before test files are required, eliminating the
90
+ "Stopped processing SimpleCov as a previous error has been detected" warning
91
+ and avoiding unnecessary coverage instrumentation overhead in every mutant fork
92
+
10
93
  ## [0.1.8] - 2026-04-16
11
94
 
12
95
  ### Added
@@ -196,7 +279,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
196
279
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
197
280
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
198
281
 
199
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.8...HEAD
282
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.10...HEAD
283
+ [0.1.10]: https://github.com/martinotten/henitai/compare/v0.1.9...v0.1.10
284
+ [0.1.9]: https://github.com/martinotten/henitai/compare/v0.1.8...v0.1.9
200
285
  [0.1.8]: https://github.com/martinotten/henitai/compare/v0.1.7...v0.1.8
201
286
  [0.1.7]: https://github.com/martinotten/henitai/compare/v0.1.6...v0.1.7
202
287
  [0.1.6]: https://github.com/martinotten/henitai/compare/v0.1.5...v0.1.6
data/README.md CHANGED
@@ -15,10 +15,9 @@ A Ruby mutation testing framework
15
15
  ## Maturaty
16
16
 
17
17
  - This is alpha software, there will be bugs
18
- - Henitai tests itself
19
- - Stryker Dashboard support is untested
20
- - Minitest support is untested
21
- - Lots of code has been carefully crafted by AI agents, not everything has been reviewed by humans (yet)
18
+ - Henitai tests itself and other projects
19
+ - It might break with a release
20
+ - If you need a mature solution for mutation testing in ruby, take a look at the [https://github.com/mbj/mutant](Mutant gem)
22
21
 
23
22
  ## What is mutation testing?
24
23
 
@@ -28,6 +27,8 @@ A mutation testing tool makes small, systematic changes — *mutants* — to you
28
27
 
29
28
  The ratio of killed mutants to total mutants is the **Mutation Score** (MS). A high mutation score is a strong quality signal.
30
29
 
30
+ As mutation testing modifies your code you make sure that your tests cannot have any unintended side-effects.
31
+
31
32
  ## Installation
32
33
 
33
34
  Add to your `Gemfile`:
@@ -56,6 +57,9 @@ bundle exec henitai run --since origin/main
56
57
  # Run on a specific subject pattern
57
58
  bundle exec henitai run 'MyClass#my_method'
58
59
  bundle exec henitai run 'MyNamespace*'
60
+
61
+ # Re-run only survivors from a prior mutation report
62
+ bundle exec henitai run --survivors-from reports/mutation-report.json
59
63
  ```
60
64
 
61
65
  Configuration lives in `.henitai.yml`:
@@ -112,6 +116,12 @@ and the terminal only shows progress plus a concise summary. Pass
112
116
  when the mutation score meets the low threshold, `1` when it does not, and `2`
113
117
  for framework errors.
114
118
 
119
+ `henitai run --survivors-from ...` performs a partial rerun: it reports only
120
+ the selected survivors, skips threshold-based exit checks, and does not update
121
+ the run trend history. Dirty worktree changes are included, so you can edit
122
+ tests locally without committing first; if source files under `includes` are
123
+ dirty, Henitai reruns the matched survivors conservatively.
124
+
115
125
  The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/workspaces/henitai/assets/schema/henitai.schema.json) for editor autocompletion.
116
126
 
117
127
  ## Operator sets
@@ -172,6 +182,10 @@ Framework integration smoke projects live under `spec/fixtures/integration_smoke
172
182
  and exercise `henitai` against small RSpec and Minitest apps via the local path
173
183
  dependency.
174
184
 
185
+ Git hook support is tracked in [`.githooks/pre-commit`](/Users/martinotten/projects/mo/henitai/.githooks/pre-commit).
186
+ Enable it with `git config core.hooksPath .githooks` so commits run RuboCop,
187
+ RSpec, and the integration smoke suite before they are created.
188
+
175
189
  A Dev Container configuration is included (`.devcontainer/`) for VS Code with the official `ruby:4`
176
190
  image, the Codex CLI, and RTK preinstalled. Codex support is bootstrapped with
177
191
  `rtk init -g --codex --auto-patch` during container creation.
data/lib/henitai/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "json"
4
5
  require "optparse"
5
6
  module Henitai
6
7
  # Command-line interface entry point.
@@ -100,7 +101,7 @@ module Henitai
100
101
 
101
102
  config = load_config(options)
102
103
  result = run_pipeline(options, config)
103
- exit(exit_status_for(result, config))
104
+ exit(exit_status_for(result, config, fail_on_survivors: options[:fail_on_survivors]))
104
105
  rescue StandardError => e
105
106
  handle_run_error(e)
106
107
  end
@@ -160,6 +161,8 @@ module Henitai
160
161
  add_operator_option(opts, options)
161
162
  add_jobs_option(opts, options)
162
163
  add_output_option(opts, options)
164
+ add_survivors_from_option(opts, options)
165
+ add_fail_on_survivors_option(opts, options)
163
166
  add_help_option(opts)
164
167
  add_version_option(opts)
165
168
  end
@@ -216,6 +219,25 @@ module Henitai
216
219
  end
217
220
  end
218
221
 
222
+ def add_survivors_from_option(opts, options)
223
+ opts.on(
224
+ "--survivors-from PATH",
225
+ "Re-run only survivors from a prior report " \
226
+ "(partial rerun; threshold checks are skipped; dirty worktrees are included)"
227
+ ) do |path|
228
+ options[:survivors_from] = path
229
+ end
230
+ end
231
+
232
+ def add_fail_on_survivors_option(opts, options)
233
+ opts.on(
234
+ "--fail-on-survivors",
235
+ "Exit 1 for partial reruns when any survivors remain (otherwise exits 0)"
236
+ ) do
237
+ options[:fail_on_survivors] = true
238
+ end
239
+ end
240
+
219
241
  def add_help_option(opts)
220
242
  opts.on("-h", "--help", "Show this help") do
221
243
  puts opts
@@ -246,6 +268,7 @@ module Henitai
246
268
  bundle exec henitai run --since origin/main
247
269
  bundle exec henitai run 'Foo::Bar#my_method'
248
270
  bundle exec henitai run 'MyNamespace*' --operators full
271
+ bundle exec henitai run --survivors-from reports/mutation-report.json
249
272
  bundle exec henitai clean
250
273
  bundle exec henitai init
251
274
  bundle exec henitai operator list
@@ -255,14 +278,55 @@ module Henitai
255
278
  end
256
279
 
257
280
  def run_pipeline(options, config)
281
+ resolved_survivors_from = resolve_survivors_from(options[:survivors_from])
258
282
  runner = Runner.new(
259
283
  config:,
260
284
  subjects: subjects_from_argv,
261
- since: options[:since]
285
+ since: options[:since],
286
+ survivors_from: resolved_survivors_from
262
287
  )
263
288
  runner.run
264
289
  end
265
290
 
291
+ def resolve_survivors_from(survivors_from)
292
+ return nil if survivors_from.nil?
293
+
294
+ # Fast path: if the path already points into reports/sessions/<session_id>/,
295
+ # keep it as-is so activation-recipes.json can be found by the runner.
296
+ report_dir = File.dirname(survivors_from)
297
+ parent_dir = File.dirname(report_dir)
298
+ # Heuristic: treat any path under a directory named "sessions" as already
299
+ # being a snapshot path; this keeps activation-recipes lookup correct.
300
+ return survivors_from if File.basename(parent_dir) == "sessions"
301
+
302
+ session_id = session_id_from_report(survivors_from)
303
+ return survivors_from if session_id.nil?
304
+
305
+ snapshot_path = survivors_snapshot_path(report_dir, session_id)
306
+ recipe_path = File.join(report_dir, "sessions", session_id, "activation-recipes.json")
307
+ return snapshot_path if File.exist?(recipe_path) && File.exist?(snapshot_path)
308
+
309
+ # If the recipes exist but the snapshot doesn't (e.g. partial cleanup),
310
+ # fall back to the path the user provided so the error message points
311
+ # at what they actually passed.
312
+
313
+ survivors_from
314
+ rescue StandardError => e
315
+ warn_survivors_from_resolution_error(survivors_from, e)
316
+ survivors_from
317
+ end
318
+
319
+ def survivors_snapshot_path(report_dir, session_id)
320
+ File.join(report_dir, "sessions", session_id, "mutation-report.json")
321
+ end
322
+
323
+ def session_id_from_report(path)
324
+ parsed = JSON.parse(File.read(path))
325
+ parsed["sessionId"]
326
+ rescue JSON::ParserError, Errno::ENOENT
327
+ nil
328
+ end
329
+
266
330
  def load_config(options)
267
331
  Configuration.load(
268
332
  path: options.fetch(:config, Configuration::CONFIG_FILE),
@@ -279,6 +343,13 @@ module Henitai
279
343
  exit 2
280
344
  end
281
345
 
346
+ def warn_survivors_from_resolution_error(survivors_from, error)
347
+ warn(
348
+ "henitai: warning: could not resolve survivors-from " \
349
+ "#{survivors_from}: #{error.class}: #{error.message}"
350
+ )
351
+ end
352
+
282
353
  def clean_summary(removed_paths)
283
354
  return "No generated report artifacts to clean" if removed_paths.empty?
284
355
 
@@ -301,7 +372,14 @@ module Henitai
301
372
  end
302
373
  end
303
374
 
304
- def exit_status_for(result, config)
375
+ def exit_status_for(result, config, fail_on_survivors: false)
376
+ if result.respond_to?(:partial_rerun?) && result.partial_rerun?
377
+ warn "henitai: partial rerun - mutation score threshold not evaluated"
378
+ return result.survived.positive? ? 1 : 0 if fail_on_survivors
379
+
380
+ return 0
381
+ end
382
+
305
383
  result.mutation_score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
306
384
  end
307
385
 
@@ -35,6 +35,7 @@ module Henitai
35
35
  end
36
36
 
37
37
  def initialize(path: CONFIG_FILE, overrides: {})
38
+ @config_dir = File.dirname(File.expand_path(path))
38
39
  raw = load_raw_configuration(path)
39
40
  unless raw.is_a?(Hash)
40
41
  raise Henitai::ConfigurationError,
@@ -54,6 +55,14 @@ module Henitai
54
55
  raw || {}
55
56
  end
56
57
 
58
+ def detect_integration
59
+ return "rspec" if File.exist?(File.join(@config_dir, ".rspec"))
60
+ return "minitest" if File.directory?(File.join(@config_dir, "test"))
61
+ return "rspec" if File.directory?(File.join(@config_dir, "spec"))
62
+
63
+ "rspec"
64
+ end
65
+
57
66
  def apply_defaults(raw)
58
67
  apply_general_defaults(raw)
59
68
  apply_mutation_defaults(raw)
@@ -61,22 +70,13 @@ module Henitai
61
70
  end
62
71
 
63
72
  def apply_general_defaults(raw)
64
- integration = raw[:integration]
65
- @integration = if integration.is_a?(Hash)
66
- integration[:name] || "rspec"
67
- elsif integration.nil?
68
- "rspec"
69
- else
70
- integration
71
- end
73
+ @integration = resolve_integration_default(raw[:integration])
72
74
  @includes = raw[:includes] || ["lib"]
73
75
  @jobs = raw.fetch(:jobs, DEFAULT_JOBS)
74
76
  @reporters = raw[:reporters] || ["terminal"]
75
77
  @reports_dir = raw[:reports_dir] || DEFAULT_REPORTS_DIR
76
78
  @all_logs = raw[:all_logs] == true
77
- # @type var empty_dashboard: Hash[Symbol, untyped]
78
- empty_dashboard = {}
79
- @dashboard = merge_defaults(empty_dashboard, raw[:dashboard])
79
+ @dashboard = default_dashboard(raw[:dashboard])
80
80
  end
81
81
 
82
82
  def apply_mutation_defaults(raw)
@@ -111,6 +111,19 @@ module Henitai
111
111
  end
112
112
  end
113
113
 
114
+ def resolve_integration_default(integration)
115
+ return integration[:name] || detect_integration if integration.is_a?(Hash)
116
+ return detect_integration if integration.nil?
117
+
118
+ integration
119
+ end
120
+
121
+ def default_dashboard(overrides)
122
+ # @type var empty_dashboard: Hash[Symbol, untyped]
123
+ empty_dashboard = {}
124
+ merge_defaults(empty_dashboard, overrides)
125
+ end
126
+
114
127
  def symbolize_keys(value)
115
128
  case value
116
129
  when Hash
@@ -18,16 +18,18 @@ module Henitai
18
18
  def ensure!(source_files:, config:, integration:, test_files: nil)
19
19
  return if source_files.empty?
20
20
 
21
+ resolved_test_files = resolve_test_files(integration, test_files)
22
+
21
23
  # Skip the bootstrap only when the coverage artifacts are both newer than
22
24
  # all watched files and actually cover the configured sources. A fresh
23
25
  # but irrelevant report (e.g. from a different working directory) must
24
26
  # still trigger a re-bootstrap rather than silently proceeding with no
25
27
  # usable coverage.
26
- unless coverage_ready?(source_files, config, integration, test_files)
27
- bootstrap_coverage(integration, config, test_files)
28
+ unless coverage_ready?(source_files, config, integration, resolved_test_files)
29
+ bootstrap_coverage(integration, config, resolved_test_files)
28
30
  end
29
31
 
30
- return if coverage_available?(source_files, config, test_files)
32
+ return if coverage_available?(source_files, config)
31
33
 
32
34
  raise CoverageError,
33
35
  "Coverage data is unavailable for the configured source files"
@@ -37,20 +39,16 @@ module Henitai
37
39
 
38
40
  attr_reader :static_filter
39
41
 
40
- def coverage_available?(source_files, config, test_files)
42
+ def coverage_available?(source_files, config)
41
43
  coverage_lines = static_filter.coverage_lines_for(config)
42
44
  covered_sources = covered_source_files(source_files, coverage_lines)
43
- existing_sources = existing_source_file_paths(source_files)
44
-
45
- return covered_sources.any? if existing_sources.empty?
46
- return covered_sources.any? if test_files
47
45
 
48
46
  covered_sources.any?
49
47
  end
50
48
 
51
49
  def coverage_ready?(source_files, config, integration, test_files)
52
- coverage_fresh?(source_files, config, integration, test_files) &&
53
- coverage_available?(source_files, config, test_files) &&
50
+ coverage_fresh?(source_files, config, test_files) &&
51
+ coverage_available?(source_files, config) &&
54
52
  per_test_coverage_ready?(source_files, config, integration, test_files)
55
53
  end
56
54
 
@@ -64,17 +62,12 @@ module Henitai
64
62
  Array(source_files).map { |path| File.expand_path(path) }
65
63
  end
66
64
 
67
- def existing_source_file_paths(source_files)
68
- source_file_paths(source_files).select { |path| File.exist?(path) }
69
- end
70
-
71
65
  # Returns true when a coverage report already exists and is newer than
72
66
  # every watched source and test file. Stale or absent reports return false.
73
- def coverage_fresh?(source_files, config, integration, test_files)
67
+ def coverage_fresh?(source_files, config, test_files)
74
68
  watched_files_fresh?(
75
69
  coverage_report_path(config),
76
70
  source_files,
77
- integration,
78
71
  test_files
79
72
  )
80
73
  end
@@ -88,9 +81,11 @@ module Henitai
88
81
  end
89
82
 
90
83
  def bootstrap_coverage(integration, config, test_files = nil)
84
+ test_files ||= integration.test_files
85
+
91
86
  with_reports_dir(config) do
92
87
  with_coverage_dir(config) do
93
- result = integration.run_suite(test_files || integration.test_files)
88
+ result = integration.run_suite(test_files)
94
89
  return if result == :survived
95
90
 
96
91
  raise CoverageError, build_bootstrap_error(result)
@@ -139,11 +134,10 @@ module Henitai
139
134
  File.join(reports_dir, "coverage")
140
135
  end
141
136
 
142
- def per_test_coverage_fresh?(source_files, config, integration, test_files)
137
+ def per_test_coverage_fresh?(source_files, config, test_files)
143
138
  watched_files_fresh?(
144
139
  per_test_coverage_report_path(config),
145
140
  source_files,
146
- integration,
147
141
  test_files
148
142
  )
149
143
  end
@@ -155,7 +149,7 @@ module Henitai
155
149
  def per_test_coverage_ready?(source_files, config, integration, test_files)
156
150
  return true unless per_test_coverage_supported?(integration)
157
151
 
158
- per_test_coverage_fresh?(source_files, config, integration, test_files) &&
152
+ per_test_coverage_fresh?(source_files, config, test_files) &&
159
153
  per_test_coverage_available?(config)
160
154
  end
161
155
 
@@ -165,21 +159,27 @@ module Henitai
165
159
  integration.per_test_coverage_supported?
166
160
  end
167
161
 
168
- def watched_files_fresh?(report_path, source_files, integration, test_files)
162
+ def watched_files_fresh?(report_path, source_files, test_files)
169
163
  # This check assumes a single writer owns the coverage artifacts for the
170
164
  # workspace. It is intentionally not an atomic snapshot-and-validate step.
171
165
  return false unless File.exist?(report_path)
172
166
 
173
167
  report_mtime = File.mtime(report_path)
174
- watched_files(source_files, integration, test_files).all? do |path|
168
+ watched_files(source_files, test_files).all? do |path|
175
169
  File.mtime(path) <= report_mtime
176
170
  rescue Errno::ENOENT
177
171
  false
178
172
  end
179
173
  end
180
174
 
181
- def watched_files(source_files, integration, test_files)
182
- Array(source_files) + Array(test_files || integration.test_files)
175
+ def watched_files(source_files, test_files)
176
+ Array(source_files) + Array(test_files)
177
+ end
178
+
179
+ def resolve_test_files(integration, test_files)
180
+ return test_files unless test_files.nil?
181
+
182
+ integration.test_files
183
183
  end
184
184
 
185
185
  def reports_dir(config)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "parallel_execution_runner"
4
+ require_relative "process_worker_runner"
4
5
 
5
6
  module Henitai
6
7
  # Runs pending mutants through the selected integration.
@@ -46,24 +47,17 @@ module Henitai
46
47
  end
47
48
 
48
49
  def run_parallel(mutants, integration, config, progress_reporter)
49
- ParallelExecutionRunner.new(
50
+ ProcessWorkerRunner.new(
50
51
  worker_count: worker_count(config)
51
52
  ).run(
52
53
  mutants,
53
54
  integration,
54
55
  config,
55
56
  progress_reporter,
56
- stdin_pipe: pipe_stdin?,
57
- process_mutant: method(:process_mutant)
57
+ test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) }
58
58
  )
59
59
  end
60
60
 
61
- def pipe_stdin?
62
- $stdin.stat.pipe?
63
- rescue Errno::EBADF
64
- false
65
- end
66
-
67
61
  def process_mutant(mutant, integration, config, progress_reporter, mutex)
68
62
  test_files = prioritized_tests_for(mutant, integration, config)
69
63
  mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
@@ -19,6 +19,21 @@ module Henitai
19
19
  stdout.split("\n").reject(&:empty?)
20
20
  end
21
21
 
22
+ def working_tree_changed_files(dir: Dir.pwd)
23
+ tracked = working_tree_tracked_files(dir)
24
+ untracked = untracked_files(dir)
25
+
26
+ (tracked + untracked).uniq
27
+ end
28
+
29
+ def head_sha(dir: Dir.pwd)
30
+ command = ["git", "-C", dir, "rev-parse", "HEAD"]
31
+ stdout, _, status = Open3.capture3(*command)
32
+ stdout.strip if status.success? && !stdout.strip.empty?
33
+ rescue Errno::ENOENT
34
+ nil
35
+ end
36
+
22
37
  def changed_methods(from:, to:, dir: Dir.pwd)
23
38
  changed_files(from:, to:, dir:).flat_map do |path|
24
39
  changed_methods_in_file(path, from:, to:, dir:)
@@ -78,5 +93,24 @@ module Henitai
78
93
 
79
94
  Open3.capture3(*command)
80
95
  end
96
+
97
+ def working_tree_tracked_files(dir)
98
+ stdout, stderr, status = git_diff(dir, "--name-only", "HEAD")
99
+
100
+ raise GitDiffError, stderr.strip unless status.success?
101
+
102
+ stdout.split("\n").reject(&:empty?)
103
+ end
104
+
105
+ def untracked_files(dir)
106
+ command = ["git"]
107
+ command += ["-C", dir] if dir
108
+ command += ["ls-files", "--others", "--exclude-standard"]
109
+ stdout, stderr, status = Open3.capture3(*command)
110
+
111
+ raise GitDiffError, stderr.strip unless status.success?
112
+
113
+ stdout.split("\n").reject(&:empty?)
114
+ end
81
115
  end
82
116
  end