henitai 0.3.1 → 0.5.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -1
  3. data/README.md +11 -1
  4. data/assets/schema/henitai.schema.json +1 -1
  5. data/lib/henitai/cli/operator_command.rb +2 -1
  6. data/lib/henitai/cli/run_options.rb +1 -1
  7. data/lib/henitai/cli.rb +1 -1
  8. data/lib/henitai/configuration.rb +9 -2
  9. data/lib/henitai/configuration_validator.rb +1 -1
  10. data/lib/henitai/dirty_source_detector.rb +53 -0
  11. data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
  12. data/lib/henitai/equivalence_detector.rb +6 -23
  13. data/lib/henitai/excluded_test_filter.rb +47 -0
  14. data/lib/henitai/execution_engine.rb +5 -11
  15. data/lib/henitai/inherited_fd_registry.rb +66 -0
  16. data/lib/henitai/integration/base.rb +7 -2
  17. data/lib/henitai/integration/child_bootstrap.rb +27 -0
  18. data/lib/henitai/integration/child_debug_log.rb +135 -0
  19. data/lib/henitai/integration/child_runtime_control.rb +6 -18
  20. data/lib/henitai/integration/loaded_features.rb +38 -0
  21. data/lib/henitai/integration/mutant_run_support.rb +5 -5
  22. data/lib/henitai/integration/rspec_child_runner.rb +16 -15
  23. data/lib/henitai/integration/rspec_process_runner.rb +7 -2
  24. data/lib/henitai/integration.rb +10 -7
  25. data/lib/henitai/mutation_skip_directives.rb +7 -1
  26. data/lib/henitai/operator.rb +12 -2
  27. data/lib/henitai/operators/hash_key_type.rb +50 -0
  28. data/lib/henitai/operators/hash_literal.rb +19 -20
  29. data/lib/henitai/operators/return_value.rb +1 -1
  30. data/lib/henitai/operators.rb +1 -0
  31. data/lib/henitai/orphan_watchdog.rb +93 -0
  32. data/lib/henitai/process_liveness.rb +41 -0
  33. data/lib/henitai/reports_directory_lock.rb +12 -11
  34. data/lib/henitai/result.rb +30 -3
  35. data/lib/henitai/runner.rb +41 -123
  36. data/lib/henitai/runner_dependencies.rb +75 -0
  37. data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
  38. data/lib/henitai/slot_scheduler/draining.rb +7 -17
  39. data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
  40. data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
  41. data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
  42. data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
  43. data/lib/henitai/slot_scheduler.rb +68 -80
  44. data/lib/henitai/source_file_selection.rb +76 -0
  45. data/lib/henitai/subject_selection.rb +33 -0
  46. data/lib/henitai/survivor_rerun_strategy.rb +7 -19
  47. data/lib/henitai/version.rb +1 -1
  48. data/lib/henitai.rb +8 -0
  49. data/sig/henitai.rbs +94 -38
  50. metadata +32 -9
  51. data/lib/henitai/integration/child_debug_support.rb +0 -119
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 425e1946a343ce94a9172939a8ad5c5d49e01fe86fbf2b01326b2f0be068df85
4
- data.tar.gz: 85867b98b26cb3710d0c1e56bd4b37b5ba78f08f47ad8378dc76ae07ce1dfc9a
3
+ metadata.gz: 34e96b6bb2bd74ac49e52a2acbe459ccb551498914d8b442f56c427c8b8ef45c
4
+ data.tar.gz: 2792932da92f73f6e4cfcc0a67aa28dca405fd4cee6d02437e13f76fbd9ee08d
5
5
  SHA512:
6
- metadata.gz: f0de6ea6a0a5a830a917f1097ac243ce43c6b8bf3bcc44ac1967133288770746d9c4df8fdee6609081613bfa174be5169e13143a22f1d3d22e8f74fd0cc008d7
7
- data.tar.gz: 37eedd8f351dbd8a9094c668ce7c56560186936f0953b098f740b6cee1654971d1b5402efe2fd0d5650442b701af7fedaea257ec9f7827575f3f04c052c2593a
6
+ metadata.gz: 5fe64e42c96aaa19d26bdf1a45631146bea7cb6d8caca5891d75ac6da0bdb3c30843ea8a83e34a67d0f90559fe41229d46a7000478cdd3708fb7929efea9f209
7
+ data.tar.gz: 39432815b88d834f6eb30be76255240b8124339505a86d8f644f2cd48ad787d85c6eb9ea9e7a85191262540e06ac467a9c32ad326a306e10953d9e758355d7bf
data/CHANGELOG.md CHANGED
@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-08-21
11
+
12
+ ### Security
13
+ - `sqlite3` requirement raised from `~> 1.7` to `>= 2.9.5, < 3`, excluding the
14
+ vulnerable releases up to and including 2.9.4. This is a runtime major bump:
15
+ applications pinned to `sqlite3` 1.x will need to upgrade before installing
16
+ this version of henitai. The root bundle also moves to `json` 2.21.2, the
17
+ patched release. `spec/infra/gemspec_dependencies_spec.rb` now asserts both
18
+ floors so a regression fails the suite rather than shipping quietly
19
+
20
+ ### Changed
21
+ - `coverage_criteria` now actually affects scoring. The block was documented,
22
+ defaulted and validated since it was introduced, but nothing ever read it —
23
+ `Result` hardcoded the `MS` numerator as `killed + timeout + runtime_error`,
24
+ so flipping `coverage_criteria.timeout` silently did nothing. It is now
25
+ consumed by `Result`, mapping `test_result` to `Killed`, `timeout` to
26
+ `Timeout` and `process_abort` to `RuntimeError`.
27
+
28
+ The shipped defaults for `timeout` and `process_abort` were `false`, which
29
+ described behavior the scorer did not have. They are now `true`, so **scores
30
+ do not move**: wiring the knob up with the old defaults intact would have
31
+ dropped timeouts and process aborts out of every user's numerator. Turning a
32
+ criterion off removes that status from the numerator only — the denominator is
33
+ unaffected, and `MSI` remains `killed / total` by definition. Note that
34
+ turning one off can move a run across its threshold and so change the exit
35
+ code
36
+ - Minimum supported Ruby lowered from 4.0.0 to 3.3.6, making henitai usable by
37
+ projects not yet on Ruby 4. The only Ruby 4-only API in use was
38
+ `Enumerable#rfind`, replaced by `reverse_each.find`; `TargetRubyVersion` is
39
+ now `3.3` (a 4.0 target made RuboCop's `Style/ReverseFind` demand the
40
+ 4.0-only form), and CI runs the floor alongside 4.0.2
41
+
42
+ ### Fixed
43
+ - Forked mutant children no longer outlive a parent that dies without warning.
44
+ Children run in their own process group, and all parent-side cleanup —
45
+ timeout kills, graceful drain, signal traps — only runs while the parent's
46
+ event loop is alive, so a `kill -9`, an OOM kill or a crash left children
47
+ running with no signal delivered: they reparented to init and each kept a
48
+ full Ruby and test-framework image resident (runs were observed leaving a
49
+ dozen behind, several gigabytes in total). Each child now starts a watchdog
50
+ that exits once its parent is gone. Set `HENITAI_CHILD_WATCHDOG=0` to
51
+ disable it, or `HENITAI_CHILD_WATCHDOG_INTERVAL` to change the poll interval
52
+ (default `1.5` seconds)
53
+ - The orphan watchdog no longer mistakes a live parent for a dead one when the
54
+ project's own suite stubs `Process.kill` or `Process.ppid`. Both primitives
55
+ are now captured as `Method` objects at load time, before any test double can
56
+ replace them — a captured `Method` keeps pointing at the original definition
57
+ even after the singleton method is redefined. This was not hypothetical: on
58
+ henitai's own dogfood run a mutant child running specs that stub
59
+ `Process.kill` to raise `ESRCH` concluded it had been orphaned and exited,
60
+ which the scheduler recorded as `CompileError`. Fixing it turned two spurious
61
+ `CompileError`s and one spurious `Timeout` back into `Killed` on a
62
+ 1019-mutant run
63
+ - A surviving child could also pin the reports-directory lock open, because
64
+ `flock` is held on the open file description that parent and child share.
65
+ Once the parent died, every later run in that directory failed with
66
+ `ConcurrentRunError` naming a pid that no longer existed. Children now close
67
+ the inherited handle immediately after forking
68
+ - `# henitai:disable HashKeyType` and `# henitai:disable
69
+ EqualityIdentityOperator` no longer raise `ConfigurationError` and abort the
70
+ run. The directive whitelist validated names against `full` rather than the
71
+ operator registry, so the hard-set names 0.4.0 introduced — the very ones
72
+ ADR-12 and the README tell users to suppress per site — were rejected as
73
+ unknown operators. `# henitai:disable EqualityIdentityOperator` worked in
74
+ 0.3.1; this restores it
75
+
76
+ ### Internal
77
+ - Test-suite coupling to private implementation details is now guarded by
78
+ `spec/infra/private_method_reach_spec.rb`, a ratchet: every spec that reaches
79
+ a private method through `send` or an instance-variable poke carries a
80
+ documented budget, and a budget may only ever go down — beating one fails the
81
+ suite until the number is lowered in the same commit. Twelve tickets tracking
82
+ this debt had sat open while the debt itself moved into three *different*,
83
+ unticketed files, because nothing measured it
84
+ - Nine collaborators extracted, each because a rule had no public seam rather
85
+ than to satisfy the ratchet mechanically: `Integration::ChildDebugLog` and
86
+ `Integration::LoadedFeatures` (replacing `Integration::ChildDebugSupport`,
87
+ which declared `private` at the top of the module); `SlotScheduler::SlotTable`,
88
+ `RetryPolicy`, `SlotDeadline`, `TestFileSelection` and `DrainVerdict`;
89
+ `ExcludedTestFilter`; `EquivalenceDetector::OperandPredicates`;
90
+ `DirtySourceDetector`, `SourceFileSelection`, `SubjectSelection` and
91
+ `RunnerDependencies`. `SlotScheduler` and `Runner` both shed enough code to
92
+ come back under their complexity budgets
93
+
94
+ ## [0.4.0] - 2026-07-14
95
+
96
+ ### Added
97
+ - `hard` operator set (`mutation.operators: hard` / `--operators hard`), a
98
+ strict superset of `full` for usually-unkillable mutations (ADR-12):
99
+ currently `EqualityIdentityOperator` and the new `HashKeyType`
100
+ (`{ a: 1 }` -> `{ "a" => 1 }`) — framework key normalization (e.g.
101
+ ActiveRecord `order`/`where`) makes key-type mutants frequently equivalent
102
+ - `HashLiteral` gains per-pair removal (`{ a: 1, b: 2 }` -> `{ b: 2 }`);
103
+ single-pair hashes and double-splat entries are skipped
104
+
105
+ ### Changed
106
+ - `full` now means "usually killable": the symbol-key -> string-key mutation
107
+ moved from `HashLiteral` into the new hard-set `HashKeyType`, and
108
+ `EqualityIdentityOperator` moved from `full` to `hard` — `full` runs emit
109
+ fewer, higher-signal mutants
110
+
10
111
  ## [0.3.1] - 2026-07-13
11
112
 
12
113
  ### Fixed
@@ -405,7 +506,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
405
506
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
406
507
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
407
508
 
408
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.3.1...HEAD
509
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.5.0...HEAD
510
+ [0.5.0]: https://github.com/martinotten/henitai/compare/v0.4.0...v0.5.0
511
+ [0.4.0]: https://github.com/martinotten/henitai/compare/v0.3.1...v0.4.0
409
512
  [0.3.1]: https://github.com/martinotten/henitai/compare/v0.3.0...v0.3.1
410
513
  [0.3.0]: https://github.com/martinotten/henitai/compare/v0.2.1...v0.3.0
411
514
  [0.2.1]: https://github.com/martinotten/henitai/compare/v0.2.0...v0.2.1
data/README.md CHANGED
@@ -43,7 +43,7 @@ Or install globally:
43
43
  gem install henitai
44
44
  ```
45
45
 
46
- **Requires Ruby 4.0.0+**
46
+ **Requires Ruby 3.3.6+**
47
47
 
48
48
  ## Quick start
49
49
 
@@ -271,7 +271,17 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
271
271
  - `AssignmentExpression` — mutate compound assignment
272
272
  - `UnaryOperator` — remove unary `-` and `~`
273
273
  - `UpdateOperator` — swap compound assignments (`+=`↔`-=`, `*=`↔`/=`, `||=`↔`&&=`)
274
+
275
+ **Hard** — adds usually-unkillable mutations on top of full, for hunting the
276
+ last survivors (see [ADR-12](docs/architecture/adr/ADR-12-hard-operator-set.md)):
277
+
274
278
  - `EqualityIdentityOperator` — `==` ↔ `eql?`/`equal?` (hardest equality pairing to kill; see [ADR-10](docs/architecture/adr/ADR-10-split-equality-identity-mutations.md))
279
+ - `HashKeyType` — `{ a: 1 }` → `{ "a" => 1 }` (frameworks that normalize key
280
+ types, e.g. ActiveRecord `order`/`where`, make these mutants equivalent at
281
+ many call sites; disable per site with `# henitai:disable HashKeyType`)
282
+
283
+ `HashLiteral` (full set) empties the hash and removes one pair at a time
284
+ (`{ a: 1, b: 2 }` → `{}` / `{ b: 2 }`).
275
285
 
276
286
  ## Stryker Dashboard integration
277
287
 
@@ -44,7 +44,7 @@
44
44
  "additionalProperties": false,
45
45
  "properties": {
46
46
  "operators": {
47
- "enum": ["light", "full"]
47
+ "enum": ["light", "full", "hard"]
48
48
  },
49
49
  "timeout": {
50
50
  "type": "number"
@@ -14,7 +14,8 @@ module Henitai
14
14
  "StringLiteral" => ["String literals", '"foo" -> ""'],
15
15
  "ReturnValue" => ["Return expressions", "return x -> return nil"],
16
16
  "ArrayDeclaration" => ["Array literals", "[1, 2] -> []"],
17
- "HashLiteral" => ["Hash literals", "{ a: 1 } -> {}"],
17
+ "HashLiteral" => ["Hash literals", "{ a: 1, b: 2 } -> {} / { b: 2 }"],
18
+ "HashKeyType" => ["Hash key types", '{ a: 1 } -> { "a" => 1 }'],
18
19
  "RangeLiteral" => ["Range literals", "1..5 -> 1...5"],
19
20
  "SafeNavigation" => ["Safe navigation", "user&.name -> user.name"],
20
21
  "PatternMatch" => ["Pattern matching", "in { x: Integer } -> in { x: String }"],
@@ -21,7 +21,7 @@ module Henitai
21
21
  end
22
22
 
23
23
  def add_operator_option(opts, options)
24
- opts.on("--operators SET", "Operator set: light | full") do |set|
24
+ opts.on("--operators SET", "Operator set: light | full | hard") do |set|
25
25
  options[:operators] = set
26
26
  end
27
27
  end
data/lib/henitai/cli.rb CHANGED
@@ -78,7 +78,7 @@ module Henitai
78
78
 
79
79
  def help_text
80
80
  <<~HELP
81
- Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby 4 Mutation Testing
81
+ Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby Mutation Testing
82
82
 
83
83
  Usage:
84
84
  henitai run [options] [SUBJECT_PATTERN...]
@@ -26,10 +26,17 @@ module Henitai
26
26
  DEFAULT_CHECKPOINT_EVERY = 200
27
27
  DEFAULT_CHECKPOINT_INTERVAL = 30.0
28
28
  DEFAULT_REPORTS_DIR = "reports"
29
+ # All three default to true because Result's MS numerator has always counted
30
+ # killed + timeout + runtime_error unconditionally. Until 0.5.0 this block
31
+ # was validated but never read, so the shipped `false` defaults described
32
+ # behavior the scorer did not have. Wiring it up with those defaults intact
33
+ # would have silently dropped timeouts and aborts out of every user's score;
34
+ # flipping them keeps the numerator exactly as it was and makes the knob
35
+ # mean what it says. See Result::CRITERION_STATUSES for the mapping.
29
36
  DEFAULT_COVERAGE_CRITERIA = {
30
37
  test_result: true,
31
- timeout: false,
32
- process_abort: false
38
+ timeout: true,
39
+ process_abort: true
33
40
  }.freeze
34
41
  DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
35
42
  CONFIG_FILE = ".henitai.yml"
@@ -33,7 +33,7 @@ module Henitai
33
33
  VALID_THRESHOLDS_KEYS = %i[high low].freeze
34
34
  VALID_DASHBOARD_KEYS = %i[project base_url].freeze
35
35
  VALID_INTEGRATION_KEYS = %i[name].freeze
36
- VALID_OPERATORS = %i[light full].freeze
36
+ VALID_OPERATORS = %i[light full hard].freeze
37
37
  VALIDATION_STEPS = %i[
38
38
  validate_top_level_keys
39
39
  validate_integration
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Answers whether any configured source root has changed, which is what makes
5
+ # a prior run's survivor verdicts unsafe to reuse.
6
+ #
7
+ # Two change sets are considered together: files dirty in the worktree, and
8
+ # files committed since the run being reused. Test-only churn is deliberately
9
+ # ignored — a spec edit invalidates nothing about a *mutant*, only about
10
+ # whether it is still killed, which the survivor rerun is about to re-measure
11
+ # anyway.
12
+ #
13
+ # Every failure mode answers `true`. A wrong `true` costs one extra rerun; a
14
+ # wrong `false` silently reuses a stale verdict, which is the whole failure
15
+ # this guard exists to prevent.
16
+ class DirtySourceDetector
17
+ def initialize(includes:, git_diff_analyzer:)
18
+ @includes = includes
19
+ @git_diff_analyzer = git_diff_analyzer
20
+ end
21
+
22
+ def dirty?(dirty_worktree_files, git_sha: nil)
23
+ # A nil list means the worktree could not be read at all, not that it is
24
+ # clean.
25
+ return true if dirty_worktree_files.nil?
26
+
27
+ all_changed = dirty_worktree_files + committed_changed_files(git_sha)
28
+ all_changed.any? { |path| in_include_root?(normalize_path(path)) }
29
+ rescue StandardError
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def committed_changed_files(git_sha)
36
+ return [] unless git_sha
37
+
38
+ @git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
39
+ end
40
+
41
+ def include_roots
42
+ @include_roots ||= Array(@includes).map { |path| normalize_path(path) }
43
+ end
44
+
45
+ # Prefix match on a path boundary, not a bare start_with?: "lib" must not
46
+ # match "library/foo.rb".
47
+ def in_include_root?(path)
48
+ include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
49
+ end
50
+
51
+ def normalize_path(path) = File.expand_path(path)
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ class EquivalenceDetector
7
+ # Recognizes the arithmetic operators and neutral operands that make a
8
+ # mutation provably equivalent to its original -- `x + 0`, `x * 1` and
9
+ # friends.
10
+ #
11
+ # Extracted from EquivalenceDetector because these decisions are
12
+ # load-bearing for scoring: a mutant marked equivalent leaves *both* sides
13
+ # of the mutation score, so a false positive quietly changes the reported
14
+ # number. They deserve tests of their own rather than being reached through
15
+ # the detector's private interface.
16
+ class OperandPredicates
17
+ ADDITIVE = %i[+ -].freeze
18
+ MULTIPLICATIVE = %i[* / **].freeze
19
+
20
+ def additive_operator?(operator) = ADDITIVE.include?(operator)
21
+
22
+ def multiplicative_operator?(operator) = MULTIPLICATIVE.include?(operator)
23
+
24
+ # Additive identity: `x + 0` and `x - 0` both reduce to `x`.
25
+ def zero_operand?(node) = numeric_operand?(node, 0)
26
+
27
+ # Multiplicative identity: `x * 1`, `x / 1` and `x ** 1` all reduce to `x`.
28
+ def one_operand?(node) = numeric_operand?(node, 1)
29
+
30
+ private
31
+
32
+ # Reads the right-hand operand of a binary send. Guards against a
33
+ # malformed node whose operand is a bare Ruby value rather than an AST
34
+ # node -- that shape does not occur in parsed source, but the detector
35
+ # must not raise on synthesized input.
36
+ def numeric_operand?(node, value)
37
+ operand = node.children[2]
38
+ return false unless operand.is_a?(Parser::AST::Node)
39
+
40
+ case operand.type
41
+ when :int, :float
42
+ operand.children.first == value || operand.children.first == value.to_i
43
+ else
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "parser_current"
4
+ require_relative "equivalence_detector/operand_predicates"
4
5
 
5
6
  module Henitai
6
7
  # Detects obvious equivalent mutants before execution.
@@ -112,33 +113,15 @@ module Henitai
112
113
  node.is_a?(Parser::AST::Node) && node.type == type && node.children.empty?
113
114
  end
114
115
 
115
- def additive_operator?(operator)
116
- %i[+ -].include?(operator)
117
- end
116
+ def additive_operator?(operator) = operand_predicates.additive_operator?(operator)
118
117
 
119
- def multiplicative_operator?(operator)
120
- %i[* / **].include?(operator)
121
- end
118
+ def multiplicative_operator?(operator) = operand_predicates.multiplicative_operator?(operator)
122
119
 
123
- def zero_operand?(node)
124
- numeric_operand?(node, 0)
125
- end
126
-
127
- def one_operand?(node)
128
- numeric_operand?(node, 1)
129
- end
120
+ def zero_operand?(node) = operand_predicates.zero_operand?(node)
130
121
 
131
- def numeric_operand?(node, value)
132
- operand = node.children[2]
133
- return false unless operand.is_a?(Parser::AST::Node)
122
+ def one_operand?(node) = operand_predicates.one_operand?(node)
134
123
 
135
- case operand.type
136
- when :int, :float
137
- operand.children.first == value || operand.children.first == value.to_i
138
- else
139
- false
140
- end
141
- end
124
+ def operand_predicates = @operand_predicates ||= OperandPredicates.new
142
125
 
143
126
  # Detects `lhs == <singleton>` mutated to `lhs.equal?(<singleton>)` (or the
144
127
  # reverse), but only when the receiver is itself a singleton literal.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Drops test files matching any of the configured exclude globs.
5
+ #
6
+ # This keeps a mutant child from re-running tests that themselves spawn
7
+ # henitai or forked subprocesses -- the CLI and process-scheduler specs, when
8
+ # dogfooding henitai on itself -- which would otherwise multiply processes and
9
+ # log noise.
10
+ #
11
+ # Takes the pattern list rather than a configuration object: exclusion is a
12
+ # path-matching rule, and keeping it free of configuration lookup makes it
13
+ # directly testable.
14
+ class ExcludedTestFilter
15
+ # @param patterns [Array<String>, nil] exclude globs; nil and [] both mean
16
+ # "exclude nothing"
17
+ def initialize(patterns:)
18
+ @patterns = Array(patterns)
19
+ end
20
+
21
+ # @param tests [Array<String>] candidate test paths
22
+ # @return [Array<String>] paths not matched by any pattern
23
+ def reject(tests)
24
+ return tests if @patterns.empty?
25
+
26
+ tests.reject { |path| excluded?(path) }
27
+ end
28
+
29
+ private
30
+
31
+ # FNM_PATHNAME so a single `*` does not match across a directory separator.
32
+ # Without it, an exclude as narrow as "spec/a/*_spec.rb" would swallow every
33
+ # test below spec/a as well.
34
+ def excluded?(path)
35
+ candidate = File.expand_path(path)
36
+ expanded_patterns.any? do |pattern|
37
+ File.fnmatch?(pattern, candidate, File::FNM_PATHNAME)
38
+ end
39
+ end
40
+
41
+ # Both sides are expanded so a relative pattern still matches an absolute
42
+ # test path, and vice versa.
43
+ def expanded_patterns
44
+ @expanded_patterns ||= @patterns.map { |pattern| File.expand_path(pattern) }
45
+ end
46
+ end
47
+ end
@@ -109,19 +109,13 @@ module Henitai
109
109
  test_prioritizer(config).sort(tests, mutant, test_history(config))
110
110
  end
111
111
 
112
- # Drops test files matching any config.test_excludes glob. Used to keep a
113
- # mutant child from re-running tests that themselves spawn henitai/forked
114
- # subprocesses (e.g. the CLI and process-scheduler specs when dogfooding
115
- # henitai on itself), which otherwise multiplies processes and log noise.
116
112
  def reject_excluded_tests(tests, config)
117
- patterns = config.respond_to?(:test_excludes) ? Array(config.test_excludes) : []
118
- return tests if patterns.empty?
113
+ ExcludedTestFilter.new(patterns: configured_test_excludes(config)).reject(tests)
114
+ end
119
115
 
120
- expanded = patterns.map { |pattern| File.expand_path(pattern) }
121
- tests.reject do |path|
122
- candidate = File.expand_path(path)
123
- expanded.any? { |pattern| File.fnmatch?(pattern, candidate, File::FNM_PATHNAME) }
124
- end
116
+ # Specs pass bare config doubles exposing only what the example needs.
117
+ def configured_test_excludes(config)
118
+ config.respond_to?(:test_excludes) ? config.test_excludes : nil
125
119
  end
126
120
 
127
121
  def test_prioritizer(config)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Tracks parent-process file handles that a forked child must not keep open.
5
+ #
6
+ # The motivating case is the reports-directory lock. `flock` is held on the
7
+ # open file *description*, which parent and child share after a fork. If a
8
+ # child outlives its parent while holding an inherited copy of that handle,
9
+ # the lock stays held by a process that is no longer running a mutation run,
10
+ # and every later invocation fails with a ConcurrentRunError naming a dead
11
+ # pid. Closing the child's copy immediately after fork means the lock dies
12
+ # with the parent, as intended.
13
+ #
14
+ # Registration is parent-side; #close_all! is the child-side call. The
15
+ # registry deliberately holds IO objects rather than file descriptor numbers:
16
+ # a child that recreated the handle via IO.for_fd would leave the original
17
+ # object alive, and its finalizer could later close a descriptor number the
18
+ # child had since reused for something else.
19
+ module InheritedFdRegistry
20
+ @ios = []
21
+ @mutex = Mutex.new
22
+
23
+ class << self
24
+ def register(io)
25
+ @mutex.synchronize { @ios << io }
26
+ io
27
+ end
28
+
29
+ def unregister(io)
30
+ @mutex.synchronize { @ios.delete(io) }
31
+ io
32
+ end
33
+
34
+ # @return [Array<IO>] copy of the tracked handles
35
+ def registered
36
+ @mutex.synchronize { @ios.dup }
37
+ end
38
+
39
+ # Closes every tracked handle. Call this in the child, immediately after
40
+ # Process.fork.
41
+ #
42
+ # This deliberately does NOT take the mutex. `fork` can land while
43
+ # another thread in the parent holds it -- the coverage bootstrap thread
44
+ # and the scheduler's worker threads both run concurrently with spawning
45
+ # -- and only the forking thread survives into the child. Waiting on a
46
+ # mutex whose owner does not exist there would hang the child forever.
47
+ # Reading a stale snapshot is harmless; deadlocking is not.
48
+ def close_all!
49
+ ios = @ios.dup
50
+ @ios = []
51
+ ios.each { |io| close_quietly(io) }
52
+ nil
53
+ end
54
+
55
+ private
56
+
57
+ # Runs in a just-forked child, where raising would take down the whole
58
+ # mutant run rather than the one handle that failed.
59
+ def close_quietly(io)
60
+ io.close unless io.closed?
61
+ rescue IOError, SystemCallError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "../process_wakeup"
5
- require_relative "child_debug_support"
5
+ require_relative "child_debug_log"
6
6
  require_relative "child_runtime_control"
7
7
  require_relative "scenario_log_support"
8
8
 
@@ -13,9 +13,14 @@ module Henitai
13
13
  # environment helpers. Concrete adapters mix in MutantRunSupport and
14
14
  # implement #run_tests plus test selection.
15
15
  class Base
16
- include ChildDebugSupport
17
16
  include ChildRuntimeControl
18
17
 
18
+ # Child-run diagnostics. Public so the child-side modules mixed into
19
+ # concrete adapters can reach it without `send`.
20
+ def child_debug_log
21
+ @child_debug_log ||= ChildDebugLog.new
22
+ end
23
+
19
24
  # @param subject [Subject]
20
25
  # @return [Array<String>] paths to test files that cover this subject
21
26
  def select_tests(subject)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # The first thing a forked mutant child does, before any test-framework
6
+ # work begins.
7
+ #
8
+ # Extracted from the fork block so the sequence is named and unit-testable
9
+ # without forking, and so both the RSpec and Minitest paths -- which share
10
+ # MutantRunSupport#spawn_mutant -- get identical treatment.
11
+ module ChildBootstrap
12
+ # @param parent_pid [Integer] captured in the parent *before* Process.fork.
13
+ # Reading Process.ppid here instead would race the very death the
14
+ # watchdog is looking for: a parent that dies between fork and this
15
+ # line leaves the child with ppid 1 as its baseline, making it look
16
+ # permanently healthy.
17
+ def self.after_fork!(parent_pid:)
18
+ # First, so that even a crash later in this method releases the
19
+ # reports-directory lock rather than pinning it with an inherited fd.
20
+ InheritedFdRegistry.close_all!
21
+ Process.setpgid(0, 0)
22
+ OrphanWatchdog.start(parent_pid:)
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end