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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -1
  3. data/README.md +3 -1
  4. data/assets/schema/henitai.schema.json +0 -4
  5. data/lib/henitai/arid_node_filter.rb +3 -0
  6. data/lib/henitai/available_cpu_count.rb +79 -0
  7. data/lib/henitai/cli.rb +7 -4
  8. data/lib/henitai/configuration.rb +3 -5
  9. data/lib/henitai/configuration_validator.rb +1 -11
  10. data/lib/henitai/coverage_bootstrapper.rb +128 -10
  11. data/lib/henitai/coverage_report_reader.rb +67 -0
  12. data/lib/henitai/eager_load.rb +11 -0
  13. data/lib/henitai/equivalence_detector.rb +60 -1
  14. data/lib/henitai/execution_engine.rb +34 -22
  15. data/lib/henitai/integration/rspec_process_runner.rb +58 -0
  16. data/lib/henitai/integration.rb +195 -110
  17. data/lib/henitai/mutant.rb +3 -1
  18. data/lib/henitai/mutant_generator.rb +25 -48
  19. data/lib/henitai/operator.rb +6 -1
  20. data/lib/henitai/operators/assignment_expression.rb +7 -23
  21. data/lib/henitai/operators/conditional_expression.rb +1 -7
  22. data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
  23. data/lib/henitai/operators/regex_mutator.rb +89 -0
  24. data/lib/henitai/operators/string_literal.rb +2 -1
  25. data/lib/henitai/operators/unary_operator.rb +36 -0
  26. data/lib/henitai/operators/update_operator.rb +70 -0
  27. data/lib/henitai/operators.rb +4 -0
  28. data/lib/henitai/parallel_execution_runner.rb +135 -0
  29. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  30. data/lib/henitai/reporter.rb +14 -2
  31. data/lib/henitai/result.rb +16 -4
  32. data/lib/henitai/runner.rb +75 -11
  33. data/lib/henitai/scenario_execution_result.rb +31 -2
  34. data/lib/henitai/source_parser.rb +12 -1
  35. data/lib/henitai/static_filter.rb +20 -41
  36. data/lib/henitai/version.rb +1 -1
  37. data/lib/henitai.rb +3 -0
  38. data/sig/henitai.rbs +66 -10
  39. metadata +17 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d11d8d5361258fac7bddc006b8fd8485d1d085c3eaf294afc5dc97c6eed9ee7c
4
- data.tar.gz: c3620c79cd2b1e59ec43abb143775e0b5edcbc2ed89b3384caa39703e7b9759a
3
+ metadata.gz: 2380a5f3e3144cd94e68967bbf73ff7a078608e3004df80222d22646170b28b6
4
+ data.tar.gz: 06ee93b8a55d6dd72b12007e66929b2de22e8da984db3f033472868fa6380d92
5
5
  SHA512:
6
- metadata.gz: eed2ea62f262d95b79bae130a0d19437749c07b8873d532bd029755a72d9600335131167da004fe01dcb9d83427fb6155de1d841df4ea8dac0e2b4651de33d26
7
- data.tar.gz: a4709f5c4804af79ea9599a62dfce714487c6d947bc8f85459cebebe206c4f0f7df965bff8c0aedacca227dceb67b32876733e9bfb450dc96357e2ef879f07c4
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.2...HEAD
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
@@ -37,10 +37,6 @@
37
37
  "timeout": {
38
38
  "type": "number"
39
39
  },
40
- "max_mutants_per_line": {
41
- "type": "integer",
42
- "minimum": 1
43
- },
44
40
  "max_flaky_retries": {
45
41
  "type": "integer",
46
42
  "minimum": 0
@@ -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: CPU count)
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 = nil # auto-detect
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, :max_mutants_per_line, :sampling, :jobs,
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[:jobs]
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 max_mutants_per_line max_flaky_retries sampling].freeze
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
- def ensure!(source_files:, config:, integration:)
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
- bootstrap_coverage(integration, config)
14
- return if coverage_available?(source_files, config)
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
- source_file_paths(source_files).any? do |path|
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 bootstrap_coverage(integration, config)
37
- with_coverage_dir(config) do
38
- result = integration.run_suite(integration.test_files)
39
- return if result == :survived
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
- raise CoverageError, build_bootstrap_error(result)
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