henitai 0.1.10 → 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 +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +18 -4
- data/lib/henitai/cli.rb +81 -3
- data/lib/henitai/configuration.rb +24 -11
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/execution_engine.rb +3 -9
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration.rb +386 -38
- data/lib/henitai/mutant/activator.rb +14 -2
- data/lib/henitai/mutant.rb +13 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store.rb +7 -22
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +434 -0
- data/lib/henitai/reporter.rb +76 -3
- data/lib/henitai/result.rb +39 -8
- data/lib/henitai/runner.rb +203 -14
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +200 -9
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2062372801be4391691c6040c2221147a5b14ec346726466d1abe8e916a7670
|
|
4
|
+
data.tar.gz: b51bd86b3f98751b4cc28bf0e443c56158fe6051f754e2ed954dc74940824f29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d128d68a96d10a3aeccdd88654cce9ef2ad53d527f196c1a29c1f02416f7d1b045989766872eef92b1832f8a283fdc726fc3b994098b464b22d91897a2993450
|
|
7
|
+
data.tar.gz: 9b13d2a228c4fd266b49db1d102ca796cd71c534458a341565fd2c1788088bd33f22c1c32bd5c9eeaee4f72d6c3658ea431a78eb5631960299736aea9575b627
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@ 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
|
+
|
|
10
78
|
## [0.1.10] - 2026-04-16
|
|
11
79
|
|
|
12
80
|
### Fixed
|
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
|
-
-
|
|
20
|
-
-
|
|
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
|
-
|
|
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,
|
|
27
|
-
bootstrap_coverage(integration, config,
|
|
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
|
|
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
|
|
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,
|
|
53
|
-
coverage_available?(source_files, config
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
182
|
-
Array(source_files) + Array(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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -2,15 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
4
|
module Integration
|
|
5
|
+
# Tracks real OS child pids for scheduler observability.
|
|
6
|
+
# Gated on HENITAI_DEBUG_SCHEDULER=1. Thread-safe.
|
|
7
|
+
module SchedulerDiagnostics
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@intervals = []
|
|
10
|
+
@live_count = 0
|
|
11
|
+
@max_concurrent = 0
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def enabled?
|
|
15
|
+
ENV["HENITAI_DEBUG_SCHEDULER"] == "1"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def child_started(pid)
|
|
19
|
+
return unless enabled?
|
|
20
|
+
|
|
21
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@live_count += 1
|
|
24
|
+
@max_concurrent = [@max_concurrent, @live_count].max
|
|
25
|
+
@intervals << { pid: pid, started_at: started_at, ended_at: nil }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_ended(pid)
|
|
30
|
+
return if pid.nil? || !enabled?
|
|
31
|
+
|
|
32
|
+
ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@live_count -= 1
|
|
35
|
+
entry = @intervals.rfind { |i| i[:pid] == pid && i[:ended_at].nil? }
|
|
36
|
+
entry[:ended_at] = ended_at if entry
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def summary
|
|
41
|
+
@mutex.synchronize { { max_concurrent: @max_concurrent, intervals: @intervals.dup } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@intervals = []
|
|
47
|
+
@live_count = 0
|
|
48
|
+
@max_concurrent = 0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Captures the PID and log paths for a spawned mutant child process.
|
|
55
|
+
ChildHandle = Struct.new(:pid, :log_paths)
|
|
56
|
+
|
|
5
57
|
# Runs RSpec child and suite processes on behalf of the integration.
|
|
6
58
|
class RspecProcessRunner
|
|
7
59
|
def run_mutant(integration, mutant:, test_files:, timeout:)
|
|
8
|
-
|
|
9
|
-
pid
|
|
10
|
-
wait_result = integration.wait_with_timeout(pid, timeout)
|
|
11
|
-
integration.build_result(wait_result, log_paths)
|
|
60
|
+
handle = integration.spawn_mutant(mutant:, test_files:)
|
|
61
|
+
SchedulerDiagnostics.child_started(handle.pid)
|
|
62
|
+
wait_result = integration.wait_with_timeout(handle.pid, timeout)
|
|
63
|
+
integration.build_result(wait_result, handle.log_paths)
|
|
12
64
|
ensure
|
|
13
|
-
|
|
65
|
+
SchedulerDiagnostics.child_ended(handle&.pid)
|
|
66
|
+
finalize_mutant_run(integration, handle&.pid, wait_result)
|
|
14
67
|
end
|
|
15
68
|
|
|
16
69
|
def run_suite(integration, test_files, timeout:)
|
|
@@ -27,10 +80,11 @@ module Henitai
|
|
|
27
80
|
end
|
|
28
81
|
end
|
|
29
82
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
83
|
+
# Called from Integration::Rspec#spawn_mutant (and Minitest#spawn_mutant).
|
|
84
|
+
# Forks a child, sets process group, activates the mutant, runs tests.
|
|
85
|
+
# Returns a ChildHandle with the forked pid and log_paths.
|
|
86
|
+
def spawn_mutant(integration, mutant:, test_files:, log_paths:)
|
|
87
|
+
pid = Process.fork do
|
|
34
88
|
Process.setpgid(0, 0)
|
|
35
89
|
ENV["HENITAI_MUTANT_ID"] = mutant.id
|
|
36
90
|
Process.exit(
|
|
@@ -41,18 +95,17 @@ module Henitai
|
|
|
41
95
|
)
|
|
42
96
|
)
|
|
43
97
|
end
|
|
98
|
+
ChildHandle.new(pid:, log_paths:)
|
|
44
99
|
end
|
|
45
100
|
|
|
101
|
+
private
|
|
102
|
+
|
|
46
103
|
def finalize_mutant_run(integration, pid, wait_result)
|
|
47
104
|
return unless pid
|
|
48
105
|
|
|
49
106
|
integration.cleanup_process_group(pid) unless wait_result == :timeout
|
|
50
107
|
integration.reap_child(pid) if wait_result.nil?
|
|
51
108
|
end
|
|
52
|
-
|
|
53
|
-
def mutant_log_name(mutant)
|
|
54
|
-
"mutant-#{mutant.id}"
|
|
55
|
-
end
|
|
56
109
|
end
|
|
57
110
|
end
|
|
58
111
|
end
|