henitai 0.1.2 → 0.1.4
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 +105 -1
- data/README.md +3 -1
- data/assets/schema/henitai.schema.json +0 -4
- data/lib/henitai/arid_node_filter.rb +3 -0
- data/lib/henitai/available_cpu_count.rb +79 -0
- data/lib/henitai/cli.rb +7 -4
- data/lib/henitai/configuration.rb +3 -5
- data/lib/henitai/configuration_validator.rb +1 -11
- data/lib/henitai/coverage_bootstrapper.rb +128 -10
- data/lib/henitai/coverage_report_reader.rb +67 -0
- data/lib/henitai/eager_load.rb +11 -0
- data/lib/henitai/equivalence_detector.rb +60 -1
- data/lib/henitai/execution_engine.rb +34 -22
- data/lib/henitai/integration/rspec_process_runner.rb +58 -0
- data/lib/henitai/integration.rb +195 -110
- data/lib/henitai/mutant.rb +3 -1
- data/lib/henitai/mutant_generator.rb +25 -48
- data/lib/henitai/operator.rb +6 -1
- data/lib/henitai/operators/assignment_expression.rb +7 -23
- data/lib/henitai/operators/conditional_expression.rb +1 -7
- data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
- data/lib/henitai/operators/regex_mutator.rb +89 -0
- data/lib/henitai/operators/string_literal.rb +2 -1
- data/lib/henitai/operators/unary_operator.rb +36 -0
- data/lib/henitai/operators/update_operator.rb +70 -0
- data/lib/henitai/operators.rb +4 -0
- data/lib/henitai/parallel_execution_runner.rb +135 -0
- data/lib/henitai/per_test_coverage_selector.rb +60 -0
- data/lib/henitai/reporter.rb +14 -2
- data/lib/henitai/result.rb +16 -4
- data/lib/henitai/runner.rb +75 -11
- data/lib/henitai/scenario_execution_result.rb +31 -2
- data/lib/henitai/source_parser.rb +12 -1
- data/lib/henitai/static_filter.rb +20 -41
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +3 -0
- data/sig/henitai.rbs +66 -10
- metadata +17 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2380a5f3e3144cd94e68967bbf73ff7a078608e3004df80222d22646170b28b6
|
|
4
|
+
data.tar.gz: 06ee93b8a55d6dd72b12007e66929b2de22e8da984db3f033472868fa6380d92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc9efdf5285729c07f0ebaa6d487a1fc764589522172584468d4fb200ab2a35df706085f933f40a588447c9c599b6939db999ef2bceec42a014a2fe84d336b13
|
|
7
|
+
data.tar.gz: 83bc413591f334c60143ee7e8936d5fb0fc42b3e9865f470e20d9b9e85e26c18a9a03e8b3bbac2707fc385e3dd6b2e17dcc4d0599cbce2b5dd11a94704935f54
|
data/CHANGELOG.md
CHANGED
|
@@ -7,9 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.4] - 2026-04-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `StringLiteral` operator no longer generates no-op mutations where the
|
|
14
|
+
replacement equals the original value (e.g. the spurious `"" → ""` mutant
|
|
15
|
+
that was emitted for methods already returning an empty string literal)
|
|
16
|
+
- Terminal diff output now uses `display_unparse` for string literal nodes,
|
|
17
|
+
making whitespace-only mutations unambiguous in the report
|
|
18
|
+
(e.g. `""`, `" "`, and `"\n"` are now visually distinct)
|
|
19
|
+
- Targeted coverage bootstrap (`--since` / explicit subjects) now correctly
|
|
20
|
+
retriggers a full suite run when the scoped bootstrap does not produce
|
|
21
|
+
coverage for all configured source files; previously the run could raise
|
|
22
|
+
`CoverageError` even though a fallback was available
|
|
23
|
+
- Coverage formatter specs now honor `HENITAI_REPORTS_DIR`, so the baseline
|
|
24
|
+
coverage bootstrap no longer fails when the suite runs under the mutation
|
|
25
|
+
runner's configured reports directory
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- `ScenarioExecutionResult.build` factory method consolidates status and
|
|
29
|
+
exit-status derivation that was previously spread across `Integration`,
|
|
30
|
+
reducing the mutation surface of the value object
|
|
31
|
+
|
|
32
|
+
## [0.1.3] - 2026-04-13
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Four new mutation operators: `UnaryOperator` (negates boolean and numeric
|
|
36
|
+
unary expressions), `UpdateOperator` (swaps `+=`/`-=`/`*=` and targets
|
|
37
|
+
compound-assignment nodes), `RegexMutator` (replaces regex literals with
|
|
38
|
+
never-match and always-match equivalents), and `MethodChainUnwrap` (removes
|
|
39
|
+
one step from a method chain to expose intermediate values)
|
|
40
|
+
- `AvailableCpuCount`: container-aware CPU detection via cgroup v1/v2 and
|
|
41
|
+
cpuset files; the execution engine uses this to cap the default worker count
|
|
42
|
+
to the number of CPUs actually available to the process
|
|
43
|
+
- `PerTestCoverageSelector`: narrows the candidate test set for each mutant
|
|
44
|
+
using per-test line-coverage data, reducing the number of processes forked
|
|
45
|
+
for targeted runs
|
|
46
|
+
- `CoverageReportReader`: dedicated reader for `.resultset.json` and
|
|
47
|
+
`henitai_per_test.json`, giving `StaticFilter` and `PerTestCoverageSelector`
|
|
48
|
+
a single, tested JSON-parsing seam
|
|
49
|
+
- Equivalence detection now covers logical identity patterns: `false || x`,
|
|
50
|
+
`x || false`, `true && x`, `x && true` are suppressed as equivalent mutants
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
- Per-line mutation cap (`max_mutants_per_line`) removed from the generator,
|
|
54
|
+
configuration schema, and validator — see ADR-08. All syntactically valid
|
|
55
|
+
mutations on a line are now generated unconditionally
|
|
56
|
+
- Default execution mode switched to linear (single-worker) as the
|
|
57
|
+
conservative, predictable baseline; parallel mode is still available via
|
|
58
|
+
configuration
|
|
59
|
+
- `ParallelExecutionRunner` and `RspecProcessRunner` extracted from
|
|
60
|
+
`ExecutionEngine` and `Integration::Rspec` respectively, separating
|
|
61
|
+
orchestration concerns from integration concerns
|
|
62
|
+
- `wait_with_timeout`, `cleanup_process_group`, and `reap_child` promoted to
|
|
63
|
+
public helpers on `Integration::Base` so `RspecProcessRunner` can call them
|
|
64
|
+
without reflection
|
|
65
|
+
|
|
66
|
+
### Performance
|
|
67
|
+
- Coverage bootstrap freshness check: the baseline RSpec run is skipped when
|
|
68
|
+
`.resultset.json` is newer than every watched source and test file,
|
|
69
|
+
eliminating ~83 % of bootstrap wall time on repeated runs within a session
|
|
70
|
+
- Overlapped bootstrap: the baseline run starts in a background thread
|
|
71
|
+
immediately after subject resolution and runs concurrently with mutant
|
|
72
|
+
generation; only Gate 3 (StaticFilter) blocks on completion
|
|
73
|
+
- Subject-scoped bootstrap: for targeted runs (`--since` / explicit subjects),
|
|
74
|
+
only the tests that cover the selected subjects are bootstrapped; falls back
|
|
75
|
+
to the full suite when the scoped set is empty
|
|
76
|
+
- Automatic retry of the full bootstrap when a scoped bootstrap yields no
|
|
77
|
+
coverage candidates for a targeted run
|
|
78
|
+
- `SourceParser` parse cache: each source file is parsed at most once per
|
|
79
|
+
pipeline run, removing duplicate parse calls between `SubjectResolver` and
|
|
80
|
+
`MutantGenerator`
|
|
81
|
+
- `StaticFilter` path cache: `File.realpath` is called at most once per unique
|
|
82
|
+
path per filter invocation
|
|
83
|
+
- `MutantGenerator::SubjectVisitor`: subject range boundaries are pre-computed
|
|
84
|
+
at visitor construction time, eliminating one `Range` allocation per visited
|
|
85
|
+
AST node
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
- Mutant child processes now run in isolated process groups (`setpgid`);
|
|
89
|
+
`cleanup_process_group` sends `SIGTERM` to the entire group on timeout or
|
|
90
|
+
error, preventing orphaned subprocesses
|
|
91
|
+
- Pipeline error handling hardened across `CoverageBootstrapper`,
|
|
92
|
+
`ExecutionEngine`, `Runner`, and `SubjectResolver`: errors are surfaced
|
|
93
|
+
with a structured result instead of being swallowed silently
|
|
94
|
+
- Report score thresholds now reflect the final aggregated result correctly
|
|
95
|
+
- Three regressions introduced during the performance work resolved (path
|
|
96
|
+
normalisation, scoped bootstrap fallback, overlapped thread join order)
|
|
97
|
+
- RBS/Steep signatures updated for bootstrap options, integration helpers,
|
|
98
|
+
result types, and the new operators
|
|
99
|
+
|
|
10
100
|
## [0.1.2] - 2026-04-07
|
|
11
101
|
|
|
102
|
+
### Added
|
|
103
|
+
- Method coverage is now enabled in both RSpec and Minitest bootstraps, and
|
|
104
|
+
the static filter merges method-level coverage into the line map
|
|
105
|
+
|
|
12
106
|
### Fixed
|
|
107
|
+
- Coverage baseline regeneration now happens on every `henitai run`, so stale
|
|
108
|
+
coverage state does not leak between runs
|
|
109
|
+
- Coverage handling now accepts symbol-keyed `Coverage.peek_result` output and
|
|
110
|
+
canonicalizes source file keys in `henitai_per_test.json`
|
|
111
|
+
- Integration child processes isolate stdio correctly, and the integration
|
|
112
|
+
pause signature was restored so captured output stays stable
|
|
113
|
+
- Coverage checks now consider the full mutant line range instead of only the
|
|
114
|
+
starting line
|
|
13
115
|
- `Henitai::Mutant::Activator` now rewrites heredoc-backed method bodies from
|
|
14
116
|
source slices instead of unparsing the whole body, eliminating timeouts on
|
|
15
117
|
HTML reporter mutants
|
|
@@ -42,7 +144,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
42
144
|
- CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
|
|
43
145
|
- RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
|
|
44
146
|
|
|
45
|
-
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.
|
|
147
|
+
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.4...HEAD
|
|
148
|
+
[0.1.4]: https://github.com/martinotten/henitai/compare/v0.1.3...v0.1.4
|
|
149
|
+
[0.1.3]: https://github.com/martinotten/henitai/compare/v0.1.2...v0.1.3
|
|
46
150
|
[0.1.2]: https://github.com/martinotten/henitai/compare/v0.1.1...v0.1.2
|
|
47
151
|
[0.1.1]: https://github.com/martinotten/henitai/compare/v0.1.0...v0.1.1
|
|
48
152
|
[0.1.0]: https://github.com/martinotten/henitai/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -71,7 +71,6 @@ includes:
|
|
|
71
71
|
mutation:
|
|
72
72
|
operators: light # light | full
|
|
73
73
|
timeout: 10.0
|
|
74
|
-
max_mutants_per_line: 1
|
|
75
74
|
max_flaky_retries: 3
|
|
76
75
|
sampling:
|
|
77
76
|
ratio: 0.05
|
|
@@ -101,6 +100,9 @@ Per-test coverage reporting is currently wired through the RSpec child runner.
|
|
|
101
100
|
Minitest integration reuses the same selection and execution flow, but does not
|
|
102
101
|
yet enable the per-test coverage formatter.
|
|
103
102
|
|
|
103
|
+
Henitai currently defaults to linear mutant execution. Set `jobs` in
|
|
104
|
+
`.henitai.yml` or pass `--jobs N` to opt into parallel mutant execution.
|
|
105
|
+
|
|
104
106
|
By default, Henitai keeps child test output out of the live terminal. Each
|
|
105
107
|
baseline or mutant run writes captured stdout/stderr to `reports/mutation-logs/`
|
|
106
108
|
and the terminal only shows progress plus a concise summary. Pass
|
|
@@ -38,6 +38,9 @@ module Henitai
|
|
|
38
38
|
when :block
|
|
39
39
|
block_match?(node)
|
|
40
40
|
when :or_asgn
|
|
41
|
+
# Memoization-style ||= is treated as arid on purpose. UpdateOperator
|
|
42
|
+
# still mutates the &&= side of the logical pair, but the ||= source
|
|
43
|
+
# form is filtered here to avoid noisy mutants around memoization.
|
|
41
44
|
true
|
|
42
45
|
else
|
|
43
46
|
false
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Detects the effective CPU count available to the current process.
|
|
7
|
+
class AvailableCpuCount
|
|
8
|
+
class << self
|
|
9
|
+
def detect
|
|
10
|
+
counts = [Etc.nprocessors, cgroup_cpu_quota, cpuset_cpu_count].compact.select(&:positive?)
|
|
11
|
+
counts.min || 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def cgroup_cpu_quota
|
|
17
|
+
parse_cpu_max(read_limit("/sys/fs/cgroup/cpu.max")) ||
|
|
18
|
+
parse_cpu_cfs(
|
|
19
|
+
read_limit("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"),
|
|
20
|
+
read_limit("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cpuset_cpu_count
|
|
25
|
+
count_cpu_list(read_limit("/sys/fs/cgroup/cpuset.cpus.effective")) ||
|
|
26
|
+
count_cpu_list(read_limit("/sys/fs/cgroup/cpuset.cpus"))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read_limit(path)
|
|
30
|
+
return unless File.file?(path)
|
|
31
|
+
|
|
32
|
+
File.read(path).strip
|
|
33
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_cpu_max(value)
|
|
38
|
+
return if value.nil? || value.empty?
|
|
39
|
+
|
|
40
|
+
quota, period = value.split
|
|
41
|
+
return if quota == "max"
|
|
42
|
+
|
|
43
|
+
quota_count(quota, period)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_cpu_cfs(quota, period)
|
|
47
|
+
return if quota.nil? || period.nil? || quota == "-1"
|
|
48
|
+
|
|
49
|
+
quota_count(quota, period)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def quota_count(quota, period)
|
|
53
|
+
quota_value = Integer(quota, 10)
|
|
54
|
+
period_value = Integer(period, 10)
|
|
55
|
+
return if quota_value <= 0 || period_value <= 0
|
|
56
|
+
|
|
57
|
+
[quota_value / period_value, 1].max
|
|
58
|
+
rescue ArgumentError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def count_cpu_list(value)
|
|
63
|
+
return if value.nil? || value.empty?
|
|
64
|
+
|
|
65
|
+
value.split(",").sum { |entry| cpu_list_entry_size(entry) }
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cpu_list_entry_size(entry)
|
|
71
|
+
from_text, to_text = entry.split("-", 2)
|
|
72
|
+
from = Integer(from_text, 10)
|
|
73
|
+
return 1 unless to_text
|
|
74
|
+
|
|
75
|
+
Integer(to_text, 10) - from + 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/henitai/cli.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Henitai
|
|
|
12
12
|
# --use INTEGRATION Override integration from config (e.g. rspec)
|
|
13
13
|
# --config PATH Path to .henitai.yml (default: .henitai.yml)
|
|
14
14
|
# --operators SET Operator set: light (default) | full
|
|
15
|
-
# --jobs N Number of parallel workers (default:
|
|
15
|
+
# --jobs N Number of parallel workers (default: 1)
|
|
16
16
|
# --all-logs Print all captured child logs
|
|
17
17
|
# -h, --help Show this help message
|
|
18
18
|
# -v, --version Show version
|
|
@@ -25,7 +25,6 @@ module Henitai
|
|
|
25
25
|
"mutation:",
|
|
26
26
|
" operators: light",
|
|
27
27
|
" timeout: 10.0",
|
|
28
|
-
" max_mutants_per_line: 1",
|
|
29
28
|
" max_flaky_retries: 3",
|
|
30
29
|
" sampling:",
|
|
31
30
|
" ratio: 0.05",
|
|
@@ -51,7 +50,11 @@ module Henitai
|
|
|
51
50
|
"PatternMatch" => ["Pattern matching", "in { x: Integer } -> in { x: String }"],
|
|
52
51
|
"BlockStatement" => ["Block statements", "{ do_work } -> {}"],
|
|
53
52
|
"MethodExpression" => ["Method calls", "call_service -> nil"],
|
|
54
|
-
"AssignmentExpression" => ["Assignment expressions", "x += 1 -> x -= 1"]
|
|
53
|
+
"AssignmentExpression" => ["Assignment expressions", "x += 1 -> x -= 1"],
|
|
54
|
+
"MethodChainUnwrap" => ["Method chain unwrap", "a.b.c -> a.b"],
|
|
55
|
+
"RegexMutator" => ["Regex literals", "/foo+/ -> /foo*/"],
|
|
56
|
+
"UnaryOperator" => ["Unary operators", "-x -> x"],
|
|
57
|
+
"UpdateOperator" => ["Compound assignment", "x += 1 -> x -= 1"]
|
|
55
58
|
}.freeze
|
|
56
59
|
|
|
57
60
|
def self.start(argv)
|
|
@@ -164,7 +167,7 @@ module Henitai
|
|
|
164
167
|
end
|
|
165
168
|
|
|
166
169
|
def add_jobs_option(opts, options)
|
|
167
|
-
opts.on("--jobs N", Integer, "Number of parallel workers") do |n|
|
|
170
|
+
opts.on("--jobs N", Integer, "Number of parallel workers (default: 1)") do |n|
|
|
168
171
|
options[:jobs] = n
|
|
169
172
|
end
|
|
170
173
|
end
|
|
@@ -12,8 +12,7 @@ module Henitai
|
|
|
12
12
|
class Configuration
|
|
13
13
|
DEFAULT_TIMEOUT = 10.0
|
|
14
14
|
DEFAULT_OPERATORS = :light
|
|
15
|
-
DEFAULT_JOBS =
|
|
16
|
-
DEFAULT_MAX_MUTANTS_PER_LINE = 1
|
|
15
|
+
DEFAULT_JOBS = 1
|
|
17
16
|
DEFAULT_MAX_FLAKY_RETRIES = 3
|
|
18
17
|
DEFAULT_REPORTS_DIR = "reports"
|
|
19
18
|
DEFAULT_COVERAGE_CRITERIA = {
|
|
@@ -25,7 +24,7 @@ module Henitai
|
|
|
25
24
|
CONFIG_FILE = ".henitai.yml"
|
|
26
25
|
|
|
27
26
|
attr_reader :integration, :includes, :operators, :timeout,
|
|
28
|
-
:ignore_patterns, :
|
|
27
|
+
:ignore_patterns, :sampling, :jobs,
|
|
29
28
|
:max_flaky_retries, :coverage_criteria, :thresholds,
|
|
30
29
|
:reporters, :reports_dir,
|
|
31
30
|
:dashboard, :all_logs
|
|
@@ -71,7 +70,7 @@ module Henitai
|
|
|
71
70
|
integration
|
|
72
71
|
end
|
|
73
72
|
@includes = raw[:includes] || ["lib"]
|
|
74
|
-
@jobs = raw
|
|
73
|
+
@jobs = raw.fetch(:jobs, DEFAULT_JOBS)
|
|
75
74
|
@reporters = raw[:reporters] || ["terminal"]
|
|
76
75
|
@reports_dir = raw[:reports_dir] || DEFAULT_REPORTS_DIR
|
|
77
76
|
@all_logs = raw[:all_logs] == true
|
|
@@ -86,7 +85,6 @@ module Henitai
|
|
|
86
85
|
@operators = (mutation[:operators] || DEFAULT_OPERATORS).to_sym
|
|
87
86
|
@timeout = mutation[:timeout] || DEFAULT_TIMEOUT
|
|
88
87
|
@ignore_patterns = mutation[:ignore_patterns] || []
|
|
89
|
-
@max_mutants_per_line = mutation[:max_mutants_per_line] || DEFAULT_MAX_MUTANTS_PER_LINE
|
|
90
88
|
@max_flaky_retries = if mutation.key?(:max_flaky_retries)
|
|
91
89
|
mutation[:max_flaky_retries]
|
|
92
90
|
else
|
|
@@ -16,7 +16,7 @@ module Henitai
|
|
|
16
16
|
dashboard
|
|
17
17
|
jobs
|
|
18
18
|
].freeze
|
|
19
|
-
VALID_MUTATION_KEYS = %i[operators timeout ignore_patterns
|
|
19
|
+
VALID_MUTATION_KEYS = %i[operators timeout ignore_patterns max_flaky_retries sampling].freeze
|
|
20
20
|
VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
|
|
21
21
|
VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
|
|
22
22
|
VALID_THRESHOLDS_KEYS = %i[high low].freeze
|
|
@@ -110,7 +110,6 @@ module Henitai
|
|
|
110
110
|
|
|
111
111
|
def validate_mutation_limits(value)
|
|
112
112
|
validate_timeout(value[:timeout])
|
|
113
|
-
validate_max_mutants_per_line(value[:max_mutants_per_line])
|
|
114
113
|
validate_max_flaky_retries(value[:max_flaky_retries])
|
|
115
114
|
end
|
|
116
115
|
|
|
@@ -202,15 +201,6 @@ module Henitai
|
|
|
202
201
|
end
|
|
203
202
|
end
|
|
204
203
|
|
|
205
|
-
def validate_max_mutants_per_line(value)
|
|
206
|
-
return if value.nil?
|
|
207
|
-
return if value.is_a?(Integer) && value >= 1
|
|
208
|
-
|
|
209
|
-
configuration_error(
|
|
210
|
-
"Invalid configuration value for mutation.max_mutants_per_line: expected Integer >= 1, got #{value.inspect}"
|
|
211
|
-
)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
204
|
def validate_max_flaky_retries(value)
|
|
215
205
|
return if value.nil?
|
|
216
206
|
return if value.is_a?(Integer) && value >= 0
|
|
@@ -7,11 +7,27 @@ module Henitai
|
|
|
7
7
|
@static_filter = static_filter
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Runs the test suite to collect coverage, unless a fresh report already
|
|
11
|
+
# exists.
|
|
12
|
+
#
|
|
13
|
+
# @param source_files [Array<String>] lib files whose coverage must be present
|
|
14
|
+
# @param config [Configuration]
|
|
15
|
+
# @param integration [Integration::Base]
|
|
16
|
+
# @param test_files [Array<String>, nil] test files to run; defaults to
|
|
17
|
+
# all files reported by the integration when nil
|
|
18
|
+
def ensure!(source_files:, config:, integration:, test_files: nil)
|
|
11
19
|
return if source_files.empty?
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
# Skip the bootstrap only when the coverage artifacts are both newer than
|
|
22
|
+
# all watched files and actually cover the configured sources. A fresh
|
|
23
|
+
# but irrelevant report (e.g. from a different working directory) must
|
|
24
|
+
# still trigger a re-bootstrap rather than silently proceeding with no
|
|
25
|
+
# usable coverage.
|
|
26
|
+
unless coverage_ready?(source_files, config, integration, test_files)
|
|
27
|
+
bootstrap_coverage(integration, config, test_files)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return if coverage_available?(source_files, config, test_files)
|
|
15
31
|
|
|
16
32
|
raise CoverageError,
|
|
17
33
|
"Coverage data is unavailable for the configured source files"
|
|
@@ -21,10 +37,25 @@ module Henitai
|
|
|
21
37
|
|
|
22
38
|
attr_reader :static_filter
|
|
23
39
|
|
|
24
|
-
def coverage_available?(source_files, config)
|
|
40
|
+
def coverage_available?(source_files, config, test_files)
|
|
25
41
|
coverage_lines = static_filter.coverage_lines_for(config)
|
|
42
|
+
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
|
+
|
|
48
|
+
covered_sources == existing_sources
|
|
49
|
+
end
|
|
26
50
|
|
|
27
|
-
|
|
51
|
+
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) &&
|
|
54
|
+
per_test_coverage_ready?(source_files, config, integration, test_files)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def covered_source_files(source_files, coverage_lines)
|
|
58
|
+
source_file_paths(source_files).select do |path|
|
|
28
59
|
Array(coverage_lines[path]).any?
|
|
29
60
|
end
|
|
30
61
|
end
|
|
@@ -33,12 +64,37 @@ module Henitai
|
|
|
33
64
|
Array(source_files).map { |path| File.expand_path(path) }
|
|
34
65
|
end
|
|
35
66
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
def existing_source_file_paths(source_files)
|
|
68
|
+
source_file_paths(source_files).select { |path| File.exist?(path) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns true when a coverage report already exists and is newer than
|
|
72
|
+
# every watched source and test file. Stale or absent reports return false.
|
|
73
|
+
def coverage_fresh?(source_files, config, integration, test_files)
|
|
74
|
+
watched_files_fresh?(
|
|
75
|
+
coverage_report_path(config),
|
|
76
|
+
source_files,
|
|
77
|
+
integration,
|
|
78
|
+
test_files
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def coverage_report_path(config)
|
|
83
|
+
File.join(coverage_dir(config), ".resultset.json")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def per_test_coverage_report_path(config)
|
|
87
|
+
File.join(reports_dir(config), "henitai_per_test.json")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def bootstrap_coverage(integration, config, test_files = nil)
|
|
91
|
+
with_reports_dir(config) do
|
|
92
|
+
with_coverage_dir(config) do
|
|
93
|
+
result = integration.run_suite(test_files || integration.test_files)
|
|
94
|
+
return if result == :survived
|
|
40
95
|
|
|
41
|
-
|
|
96
|
+
raise CoverageError, build_bootstrap_error(result)
|
|
97
|
+
end
|
|
42
98
|
end
|
|
43
99
|
end
|
|
44
100
|
|
|
@@ -64,11 +120,73 @@ module Henitai
|
|
|
64
120
|
end
|
|
65
121
|
end
|
|
66
122
|
|
|
123
|
+
def with_reports_dir(config)
|
|
124
|
+
original_reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", nil)
|
|
125
|
+
ENV["HENITAI_REPORTS_DIR"] = reports_dir(config)
|
|
126
|
+
yield
|
|
127
|
+
ensure
|
|
128
|
+
if original_reports_dir.nil?
|
|
129
|
+
ENV.delete("HENITAI_REPORTS_DIR")
|
|
130
|
+
else
|
|
131
|
+
ENV["HENITAI_REPORTS_DIR"] = original_reports_dir
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
67
135
|
def coverage_dir(config)
|
|
68
136
|
reports_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
|
|
69
137
|
return "coverage" if reports_dir.nil? || reports_dir.empty?
|
|
70
138
|
|
|
71
139
|
File.join(reports_dir, "coverage")
|
|
72
140
|
end
|
|
141
|
+
|
|
142
|
+
def per_test_coverage_fresh?(source_files, config, integration, test_files)
|
|
143
|
+
watched_files_fresh?(
|
|
144
|
+
per_test_coverage_report_path(config),
|
|
145
|
+
source_files,
|
|
146
|
+
integration,
|
|
147
|
+
test_files
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def per_test_coverage_available?(config)
|
|
152
|
+
File.exist?(per_test_coverage_report_path(config))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def per_test_coverage_ready?(source_files, config, integration, test_files)
|
|
156
|
+
return true unless per_test_coverage_supported?(integration)
|
|
157
|
+
|
|
158
|
+
per_test_coverage_fresh?(source_files, config, integration, test_files) &&
|
|
159
|
+
per_test_coverage_available?(config)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def per_test_coverage_supported?(integration)
|
|
163
|
+
return false unless integration.respond_to?(:per_test_coverage_supported?)
|
|
164
|
+
|
|
165
|
+
integration.per_test_coverage_supported?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def watched_files_fresh?(report_path, source_files, integration, test_files)
|
|
169
|
+
# This check assumes a single writer owns the coverage artifacts for the
|
|
170
|
+
# workspace. It is intentionally not an atomic snapshot-and-validate step.
|
|
171
|
+
return false unless File.exist?(report_path)
|
|
172
|
+
|
|
173
|
+
report_mtime = File.mtime(report_path)
|
|
174
|
+
watched_files(source_files, integration, test_files).all? do |path|
|
|
175
|
+
File.mtime(path) <= report_mtime
|
|
176
|
+
rescue Errno::ENOENT
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def watched_files(source_files, integration, test_files)
|
|
182
|
+
Array(source_files) + Array(test_files || integration.test_files)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def reports_dir(config)
|
|
186
|
+
return "coverage" unless config.respond_to?(:reports_dir)
|
|
187
|
+
return "coverage" if config.reports_dir.nil? || config.reports_dir.empty?
|
|
188
|
+
|
|
189
|
+
config.reports_dir
|
|
190
|
+
end
|
|
73
191
|
end
|
|
74
192
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Reads coverage report formats used by Henitai.
|
|
7
|
+
class CoverageReportReader
|
|
8
|
+
DEFAULT_COVERAGE_REPORT_PATH = "coverage/.resultset.json"
|
|
9
|
+
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = "coverage/henitai_per_test.json"
|
|
10
|
+
|
|
11
|
+
def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
|
|
12
|
+
return {} unless File.exist?(path)
|
|
13
|
+
|
|
14
|
+
coverage = Hash.new { |hash, key| hash[key] = [] }
|
|
15
|
+
JSON.parse(File.read(path)).each_value do |result|
|
|
16
|
+
result.fetch("coverage", {}).each do |file, file_coverage|
|
|
17
|
+
coverage[normalize_path(file)].concat(covered_lines(file_coverage))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
coverage.transform_values(&:uniq).transform_values(&:sort)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_lines_by_file(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
|
|
25
|
+
return {} unless File.exist?(path)
|
|
26
|
+
|
|
27
|
+
parsed = JSON.parse(File.read(path))
|
|
28
|
+
return {} unless parsed.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
parsed.transform_values do |coverage|
|
|
31
|
+
normalize_test_coverage(coverage)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def covered_lines(file_coverage)
|
|
38
|
+
Array(file_coverage["lines"]).each_with_index.filter_map do |count, index|
|
|
39
|
+
index + 1 if count.to_i.positive?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_test_coverage(coverage)
|
|
44
|
+
case coverage
|
|
45
|
+
when Hash
|
|
46
|
+
coverage.transform_values do |lines|
|
|
47
|
+
Array(lines).grep(Integer).uniq.sort
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
Array(coverage).grep(Integer).uniq.sort
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize_path(path)
|
|
55
|
+
@normalize_path_cache ||= {}
|
|
56
|
+
return @normalize_path_cache[path] if @normalize_path_cache.key?(path)
|
|
57
|
+
|
|
58
|
+
expanded = File.expand_path(path)
|
|
59
|
+
resolved = begin
|
|
60
|
+
File.realpath(expanded)
|
|
61
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
62
|
+
expanded
|
|
63
|
+
end
|
|
64
|
+
@normalize_path_cache[path] = resolved
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "henitai"
|
|
4
|
+
|
|
5
|
+
# Force all autoloaded constants to load so mutation testing tools
|
|
6
|
+
# (e.g. mutant) can discover subjects via ObjectSpace.
|
|
7
|
+
SIDE_EFFECT_FILES = %w[minitest_simplecov.rb rspec_coverage_formatter.rb].freeze
|
|
8
|
+
|
|
9
|
+
Dir[File.join(__dir__, "**/*.rb")].each do |f|
|
|
10
|
+
require f unless SIDE_EFFECT_FILES.any? { |name| f.end_with?(name) }
|
|
11
|
+
end
|