henitai 0.1.2 → 0.1.3

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -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 +112 -7
  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 +192 -90
  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/unary_operator.rb +36 -0
  25. data/lib/henitai/operators/update_operator.rb +70 -0
  26. data/lib/henitai/operators.rb +4 -0
  27. data/lib/henitai/parallel_execution_runner.rb +135 -0
  28. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  29. data/lib/henitai/result.rb +16 -4
  30. data/lib/henitai/runner.rb +75 -11
  31. data/lib/henitai/source_parser.rb +12 -1
  32. data/lib/henitai/static_filter.rb +20 -41
  33. data/lib/henitai/version.rb +1 -1
  34. data/lib/henitai.rb +3 -0
  35. data/sig/henitai.rbs +59 -10
  36. metadata +16 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d11d8d5361258fac7bddc006b8fd8485d1d085c3eaf294afc5dc97c6eed9ee7c
4
- data.tar.gz: c3620c79cd2b1e59ec43abb143775e0b5edcbc2ed89b3384caa39703e7b9759a
3
+ metadata.gz: 748dc8e07fefafa1e15d8f84b6e047896d20615362c1edc4d69e6321acd3db4d
4
+ data.tar.gz: d61c9559adc952e63417d3edffc9826093ea5e0e9b3298761badf85080e11a12
5
5
  SHA512:
6
- metadata.gz: eed2ea62f262d95b79bae130a0d19437749c07b8873d532bd029755a72d9600335131167da004fe01dcb9d83427fb6155de1d841df4ea8dac0e2b4651de33d26
7
- data.tar.gz: a4709f5c4804af79ea9599a62dfce714487c6d947bc8f85459cebebe206c4f0f7df965bff8c0aedacca227dceb67b32876733e9bfb450dc96357e2ef879f07c4
6
+ metadata.gz: 3172df8aa6dac29dd8498e0c2b697e0a1698a049ce86db1b976a5d93163206e146b16b2a641dd77088e0cc58e17238ffa657f704732a0e574d93a331faa80be0
7
+ data.tar.gz: 6458897e5a6c53321362b5f65cfd9018c8ca415ccad74a0ba8303c56336e182407b50cd3ac02d976bb55d2eb284444676de137719194c7bbb04f20717903f082
data/CHANGELOG.md CHANGED
@@ -7,9 +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.1.3] - 2026-04-13
11
+
12
+ ### Added
13
+ - Four new mutation operators: `UnaryOperator` (negates boolean and numeric
14
+ unary expressions), `UpdateOperator` (swaps `+=`/`-=`/`*=` and targets
15
+ compound-assignment nodes), `RegexMutator` (replaces regex literals with
16
+ never-match and always-match equivalents), and `MethodChainUnwrap` (removes
17
+ one step from a method chain to expose intermediate values)
18
+ - `AvailableCpuCount`: container-aware CPU detection via cgroup v1/v2 and
19
+ cpuset files; the execution engine uses this to cap the default worker count
20
+ to the number of CPUs actually available to the process
21
+ - `PerTestCoverageSelector`: narrows the candidate test set for each mutant
22
+ using per-test line-coverage data, reducing the number of processes forked
23
+ for targeted runs
24
+ - `CoverageReportReader`: dedicated reader for `.resultset.json` and
25
+ `henitai_per_test.json`, giving `StaticFilter` and `PerTestCoverageSelector`
26
+ a single, tested JSON-parsing seam
27
+ - Equivalence detection now covers logical identity patterns: `false || x`,
28
+ `x || false`, `true && x`, `x && true` are suppressed as equivalent mutants
29
+
30
+ ### Changed
31
+ - Per-line mutation cap (`max_mutants_per_line`) removed from the generator,
32
+ configuration schema, and validator — see ADR-08. All syntactically valid
33
+ mutations on a line are now generated unconditionally
34
+ - Default execution mode switched to linear (single-worker) as the
35
+ conservative, predictable baseline; parallel mode is still available via
36
+ configuration
37
+ - `ParallelExecutionRunner` and `RspecProcessRunner` extracted from
38
+ `ExecutionEngine` and `Integration::Rspec` respectively, separating
39
+ orchestration concerns from integration concerns
40
+ - `wait_with_timeout`, `cleanup_process_group`, and `reap_child` promoted to
41
+ public helpers on `Integration::Base` so `RspecProcessRunner` can call them
42
+ without reflection
43
+
44
+ ### Performance
45
+ - Coverage bootstrap freshness check: the baseline RSpec run is skipped when
46
+ `.resultset.json` is newer than every watched source and test file,
47
+ eliminating ~83 % of bootstrap wall time on repeated runs within a session
48
+ - Overlapped bootstrap: the baseline run starts in a background thread
49
+ immediately after subject resolution and runs concurrently with mutant
50
+ generation; only Gate 3 (StaticFilter) blocks on completion
51
+ - Subject-scoped bootstrap: for targeted runs (`--since` / explicit subjects),
52
+ only the tests that cover the selected subjects are bootstrapped; falls back
53
+ to the full suite when the scoped set is empty
54
+ - Automatic retry of the full bootstrap when a scoped bootstrap yields no
55
+ coverage candidates for a targeted run
56
+ - `SourceParser` parse cache: each source file is parsed at most once per
57
+ pipeline run, removing duplicate parse calls between `SubjectResolver` and
58
+ `MutantGenerator`
59
+ - `StaticFilter` path cache: `File.realpath` is called at most once per unique
60
+ path per filter invocation
61
+ - `MutantGenerator::SubjectVisitor`: subject range boundaries are pre-computed
62
+ at visitor construction time, eliminating one `Range` allocation per visited
63
+ AST node
64
+
65
+ ### Fixed
66
+ - Mutant child processes now run in isolated process groups (`setpgid`);
67
+ `cleanup_process_group` sends `SIGTERM` to the entire group on timeout or
68
+ error, preventing orphaned subprocesses
69
+ - Pipeline error handling hardened across `CoverageBootstrapper`,
70
+ `ExecutionEngine`, `Runner`, and `SubjectResolver`: errors are surfaced
71
+ with a structured result instead of being swallowed silently
72
+ - Report score thresholds now reflect the final aggregated result correctly
73
+ - Three regressions introduced during the performance work resolved (path
74
+ normalisation, scoped bootstrap fallback, overlapped thread join order)
75
+ - RBS/Steep signatures updated for bootstrap options, integration helpers,
76
+ result types, and the new operators
77
+
10
78
  ## [0.1.2] - 2026-04-07
11
79
 
80
+ ### Added
81
+ - Method coverage is now enabled in both RSpec and Minitest bootstraps, and
82
+ the static filter merges method-level coverage into the line map
83
+
12
84
  ### Fixed
85
+ - Coverage baseline regeneration now happens on every `henitai run`, so stale
86
+ coverage state does not leak between runs
87
+ - Coverage handling now accepts symbol-keyed `Coverage.peek_result` output and
88
+ canonicalizes source file keys in `henitai_per_test.json`
89
+ - Integration child processes isolate stdio correctly, and the integration
90
+ pause signature was restored so captured output stays stable
91
+ - Coverage checks now consider the full mutant line range instead of only the
92
+ starting line
13
93
  - `Henitai::Mutant::Activator` now rewrites heredoc-backed method bodies from
14
94
  source slices instead of unparsing the whole body, eliminating timeouts on
15
95
  HTML reporter mutants
@@ -42,7 +122,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
42
122
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
43
123
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
44
124
 
45
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.2...HEAD
125
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.3...HEAD
126
+ [0.1.3]: https://github.com/martinotten/henitai/compare/v0.1.2...v0.1.3
46
127
  [0.1.2]: https://github.com/martinotten/henitai/compare/v0.1.1...v0.1.2
47
128
  [0.1.1]: https://github.com/martinotten/henitai/compare/v0.1.0...v0.1.1
48
129
  [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,10 +7,26 @@ 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)
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
+
14
30
  return if coverage_available?(source_files, config)
15
31
 
16
32
  raise CoverageError,
@@ -29,16 +45,43 @@ module Henitai
29
45
  end
30
46
  end
31
47
 
48
+ def coverage_ready?(source_files, config, integration, test_files)
49
+ coverage_fresh?(source_files, config, integration, test_files) &&
50
+ coverage_available?(source_files, config) &&
51
+ per_test_coverage_ready?(source_files, config, integration, test_files)
52
+ end
53
+
32
54
  def source_file_paths(source_files)
33
55
  Array(source_files).map { |path| File.expand_path(path) }
34
56
  end
35
57
 
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
58
+ # Returns true when a coverage report already exists and is newer than
59
+ # every watched source and test file. Stale or absent reports return false.
60
+ def coverage_fresh?(source_files, config, integration, test_files)
61
+ watched_files_fresh?(
62
+ coverage_report_path(config),
63
+ source_files,
64
+ integration,
65
+ test_files
66
+ )
67
+ end
68
+
69
+ def coverage_report_path(config)
70
+ File.join(coverage_dir(config), ".resultset.json")
71
+ end
72
+
73
+ def per_test_coverage_report_path(config)
74
+ File.join(reports_dir(config), "henitai_per_test.json")
75
+ end
76
+
77
+ def bootstrap_coverage(integration, config, test_files = nil)
78
+ with_reports_dir(config) do
79
+ with_coverage_dir(config) do
80
+ result = integration.run_suite(test_files || integration.test_files)
81
+ return if result == :survived
40
82
 
41
- raise CoverageError, build_bootstrap_error(result)
83
+ raise CoverageError, build_bootstrap_error(result)
84
+ end
42
85
  end
43
86
  end
44
87
 
@@ -64,11 +107,73 @@ module Henitai
64
107
  end
65
108
  end
66
109
 
110
+ def with_reports_dir(config)
111
+ original_reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", nil)
112
+ ENV["HENITAI_REPORTS_DIR"] = reports_dir(config)
113
+ yield
114
+ ensure
115
+ if original_reports_dir.nil?
116
+ ENV.delete("HENITAI_REPORTS_DIR")
117
+ else
118
+ ENV["HENITAI_REPORTS_DIR"] = original_reports_dir
119
+ end
120
+ end
121
+
67
122
  def coverage_dir(config)
68
123
  reports_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
69
124
  return "coverage" if reports_dir.nil? || reports_dir.empty?
70
125
 
71
126
  File.join(reports_dir, "coverage")
72
127
  end
128
+
129
+ def per_test_coverage_fresh?(source_files, config, integration, test_files)
130
+ watched_files_fresh?(
131
+ per_test_coverage_report_path(config),
132
+ source_files,
133
+ integration,
134
+ test_files
135
+ )
136
+ end
137
+
138
+ def per_test_coverage_available?(config)
139
+ File.exist?(per_test_coverage_report_path(config))
140
+ end
141
+
142
+ def per_test_coverage_ready?(source_files, config, integration, test_files)
143
+ return true unless per_test_coverage_supported?(integration)
144
+
145
+ per_test_coverage_fresh?(source_files, config, integration, test_files) &&
146
+ per_test_coverage_available?(config)
147
+ end
148
+
149
+ def per_test_coverage_supported?(integration)
150
+ return false unless integration.respond_to?(:per_test_coverage_supported?)
151
+
152
+ integration.per_test_coverage_supported?
153
+ end
154
+
155
+ def watched_files_fresh?(report_path, source_files, integration, test_files)
156
+ # This check assumes a single writer owns the coverage artifacts for the
157
+ # workspace. It is intentionally not an atomic snapshot-and-validate step.
158
+ return false unless File.exist?(report_path)
159
+
160
+ report_mtime = File.mtime(report_path)
161
+ watched_files(source_files, integration, test_files).all? do |path|
162
+ File.mtime(path) <= report_mtime
163
+ rescue Errno::ENOENT
164
+ false
165
+ end
166
+ end
167
+
168
+ def watched_files(source_files, integration, test_files)
169
+ Array(source_files) + Array(test_files || integration.test_files)
170
+ end
171
+
172
+ def reports_dir(config)
173
+ return "coverage" unless config.respond_to?(:reports_dir)
174
+ return "coverage" if config.reports_dir.nil? || config.reports_dir.empty?
175
+
176
+ config.reports_dir
177
+ end
73
178
  end
74
179
  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
@@ -10,7 +10,7 @@ module Henitai
10
10
  # obvious enough to be useful.
11
11
  class EquivalenceDetector
12
12
  def analyze(mutant)
13
- return mutant unless equivalent_arithmetic_mutation?(mutant)
13
+ return mutant unless equivalent_mutation?(mutant)
14
14
 
15
15
  mutant.status = :equivalent
16
16
  mutant
@@ -18,6 +18,10 @@ module Henitai
18
18
 
19
19
  private
20
20
 
21
+ def equivalent_mutation?(mutant)
22
+ equivalent_arithmetic_mutation?(mutant) || equivalent_logical_mutation?(mutant)
23
+ end
24
+
21
25
  def equivalent_arithmetic_mutation?(mutant)
22
26
  original = mutant.original_node
23
27
  mutated = mutant.mutated_node
@@ -50,6 +54,61 @@ module Henitai
50
54
  one_operand?(mutated)
51
55
  end
52
56
 
57
+ def equivalent_logical_mutation?(mutant)
58
+ original = mutant.original_node
59
+ mutated = mutant.mutated_node
60
+ return false unless logical_node?(original)
61
+
62
+ logical_identity_equivalent?(original, mutated)
63
+ end
64
+
65
+ def logical_node?(node)
66
+ node.is_a?(Parser::AST::Node) && %i[and or].include?(node.type)
67
+ end
68
+
69
+ def logical_identity_equivalent?(original, mutated)
70
+ case original.type
71
+ when :or
72
+ false_identity_equivalent?(original, mutated)
73
+ when :and
74
+ true_identity_equivalent?(original, mutated)
75
+ else
76
+ false
77
+ end
78
+ end
79
+
80
+ def false_identity_equivalent?(original, mutated)
81
+ return true if false_operand?(original.children[0]) && same_node?(mutated, original.children[1])
82
+ return true if false_operand?(original.children[1]) && same_node?(mutated, original.children[0])
83
+
84
+ false
85
+ end
86
+
87
+ def true_identity_equivalent?(original, mutated)
88
+ return true if true_operand?(original.children[0]) && same_node?(mutated, original.children[1])
89
+ return true if true_operand?(original.children[1]) && same_node?(mutated, original.children[0])
90
+
91
+ false
92
+ end
93
+
94
+ def false_operand?(node)
95
+ # Parser uses :true / :false node types, so the AST symbols are intentional.
96
+ # rubocop:disable Lint/BooleanSymbol
97
+ boolean_literal?(node, :false)
98
+ # rubocop:enable Lint/BooleanSymbol
99
+ end
100
+
101
+ def true_operand?(node)
102
+ # Parser uses :true / :false node types, so the AST symbols are intentional.
103
+ # rubocop:disable Lint/BooleanSymbol
104
+ boolean_literal?(node, :true)
105
+ # rubocop:enable Lint/BooleanSymbol
106
+ end
107
+
108
+ def boolean_literal?(node, type)
109
+ node.is_a?(Parser::AST::Node) && node.type == type && node.children.empty?
110
+ end
111
+
53
112
  def additive_operator?(operator)
54
113
  %i[+ -].include?(operator)
55
114
  end