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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -1
- data/README.md +11 -1
- data/assets/schema/henitai.schema.json +1 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +1 -1
- data/lib/henitai/configuration.rb +9 -2
- data/lib/henitai/configuration_validator.rb +1 -1
- data/lib/henitai/dirty_source_detector.rb +53 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
- data/lib/henitai/equivalence_detector.rb +6 -23
- data/lib/henitai/excluded_test_filter.rb +47 -0
- data/lib/henitai/execution_engine.rb +5 -11
- data/lib/henitai/inherited_fd_registry.rb +66 -0
- data/lib/henitai/integration/base.rb +7 -2
- data/lib/henitai/integration/child_bootstrap.rb +27 -0
- data/lib/henitai/integration/child_debug_log.rb +135 -0
- data/lib/henitai/integration/child_runtime_control.rb +6 -18
- data/lib/henitai/integration/loaded_features.rb +38 -0
- data/lib/henitai/integration/mutant_run_support.rb +5 -5
- data/lib/henitai/integration/rspec_child_runner.rb +16 -15
- data/lib/henitai/integration/rspec_process_runner.rb +7 -2
- data/lib/henitai/integration.rb +10 -7
- data/lib/henitai/mutation_skip_directives.rb +7 -1
- data/lib/henitai/operator.rb +12 -2
- data/lib/henitai/operators/hash_key_type.rb +50 -0
- data/lib/henitai/operators/hash_literal.rb +19 -20
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/orphan_watchdog.rb +93 -0
- data/lib/henitai/process_liveness.rb +41 -0
- data/lib/henitai/reports_directory_lock.rb +12 -11
- data/lib/henitai/result.rb +30 -3
- data/lib/henitai/runner.rb +41 -123
- data/lib/henitai/runner_dependencies.rb +75 -0
- data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
- data/lib/henitai/slot_scheduler/draining.rb +7 -17
- data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
- data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
- data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
- data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
- data/lib/henitai/slot_scheduler.rb +68 -80
- data/lib/henitai/source_file_selection.rb +76 -0
- data/lib/henitai/subject_selection.rb +33 -0
- data/lib/henitai/survivor_rerun_strategy.rb +7 -19
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +94 -38
- metadata +32 -9
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34e96b6bb2bd74ac49e52a2acbe459ccb551498914d8b442f56c427c8b8ef45c
|
|
4
|
+
data.tar.gz: 2792932da92f73f6e4cfcc0a67aa28dca405fd4cee6d02437e13f76fbd9ee08d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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 }"],
|
data/lib/henitai/cli.rb
CHANGED
|
@@ -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:
|
|
32
|
-
process_abort:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
113
|
+
ExcludedTestFilter.new(patterns: configured_test_excludes(config)).reject(tests)
|
|
114
|
+
end
|
|
119
115
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 "
|
|
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
|