henitai 0.2.1 → 0.3.1
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 +106 -1
- data/README.md +125 -3
- data/assets/schema/henitai.schema.json +35 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +14 -8
- data/lib/henitai/cli/command_support.rb +2 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/options.rb +21 -57
- data/lib/henitai/cli/run_command.rb +36 -3
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +12 -4
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +41 -9
- data/lib/henitai/configuration_validator/rules.rb +18 -0
- data/lib/henitai/configuration_validator/scalars.rb +46 -0
- data/lib/henitai/configuration_validator.rb +8 -1
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +111 -50
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/child_debug_support.rb +6 -2
- data/lib/henitai/integration/coverage_suppression.rb +9 -0
- data/lib/henitai/integration/minitest.rb +11 -43
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +3 -3
- data/lib/henitai/integration/rspec_test_selection.rb +3 -0
- data/lib/henitai/integration/scenario_log_support.rb +67 -20
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +14 -0
- data/lib/henitai/mutant.rb +13 -6
- data/lib/henitai/mutant_history_store/sql.rb +21 -3
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +62 -12
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +81 -0
- data/lib/henitai/per_test_coverage_collector.rb +20 -5
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +248 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +75 -10
- data/lib/henitai/runner.rb +102 -43
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +21 -15
- data/lib/henitai/slot_scheduler.rb +73 -15
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +1 -1
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +14 -1
- data/sig/henitai.rbs +235 -25
- metadata +22 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 425e1946a343ce94a9172939a8ad5c5d49e01fe86fbf2b01326b2f0be068df85
|
|
4
|
+
data.tar.gz: 85867b98b26cb3710d0c1e56bd4b37b5ba78f08f47ad8378dc76ae07ce1dfc9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0de6ea6a0a5a830a917f1097ac243ce43c6b8bf3bcc44ac1967133288770746d9c4df8fdee6609081613bfa174be5169e13143a22f1d3d22e8f74fd0cc008d7
|
|
7
|
+
data.tar.gz: 37eedd8f351dbd8a9094c668ce7c56560186936f0953b098f740b6cee1654971d1b5402efe2fd0d5650442b701af7fedaea257ec9f7827575f3f04c052c2593a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.1] - 2026-07-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Per-mutant log capture no longer crashes with
|
|
14
|
+
`Encoding::UndefinedConversionError` when the host app sets
|
|
15
|
+
`Encoding.default_internal` (as Rails test environments do) and a child
|
|
16
|
+
process prints multibyte output: the capped log streams are opened in
|
|
17
|
+
binary mode, so pipe chunks are written byte-for-byte. Also keeps a
|
|
18
|
+
multibyte character split across drain chunks (or truncated at the cap)
|
|
19
|
+
from corrupting the file.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `--since REF` now includes the working tree: tracked files with
|
|
23
|
+
uncommitted changes and untracked files count as changed, matching what
|
|
24
|
+
actually gets tested. Previously only committed changes in `REF..HEAD`
|
|
25
|
+
were considered, so pre-commit runs silently selected nothing.
|
|
26
|
+
- `--since REF` is test-aware: a changed test file selects the source files
|
|
27
|
+
it covers, using the per-test coverage map from the previous run
|
|
28
|
+
(`henitai_per_test.json`). Editing only a test now re-tests the subjects
|
|
29
|
+
that test can kill. First run without a map: no expansion.
|
|
30
|
+
- An empty `--since` run now says why: the terminal summary appends
|
|
31
|
+
`No mutants: no configured source files changed since REF.` instead of
|
|
32
|
+
printing an unexplained all-zero table. `Result` gained a `since`
|
|
33
|
+
attribute to carry the scope.
|
|
34
|
+
|
|
35
|
+
## [0.3.0] - 2026-07-13
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- `henitai run --incremental`: verdict reuse from the history store. Killed
|
|
39
|
+
verdicts are reused when the subject's source and every covering test file
|
|
40
|
+
are byte-identical to what was recorded; Survived verdicts are additionally
|
|
41
|
+
gated on the live per-test covering set (membership and content) plus a
|
|
42
|
+
run-level dependency fingerprint over spec helpers, support files,
|
|
43
|
+
fixtures/factories, `Gemfile.lock`, `.henitai.yml`, and `.rspec` (ADR-11).
|
|
44
|
+
Reused mutants carry `fromCache: true` in the JSON report; the terminal
|
|
45
|
+
prints the reused split and an executed-only MS/MSI line. `--force` bypasses
|
|
46
|
+
reuse; every doubt (missing per-test map, ambiguous ids, legacy rows,
|
|
47
|
+
timeout/error verdicts) resolves to re-execution
|
|
48
|
+
- Canonical report merge: scoped and partial runs (`--since`, subject
|
|
49
|
+
patterns, `--survivors-from`) merge into `reports/mutation-report.json`
|
|
50
|
+
keyed by `stableId` instead of overwriting it; full runs still replace it
|
|
51
|
+
- Checkpoint reporting: long runs persist partial JSON/HTML results
|
|
52
|
+
incrementally, so a crashed or aborted run keeps its finished verdicts
|
|
53
|
+
- Reports-directory locking: concurrent runs against the same `reports_dir`
|
|
54
|
+
fail fast with `ConcurrentRunError`; when the recorded owner pid is dead the
|
|
55
|
+
error names the likely orphaned child and the `lsof` command to find it
|
|
56
|
+
- `henitai run --dry-run`: list the post-filter mutant set without executing
|
|
57
|
+
any tests (always exits 0)
|
|
58
|
+
- `--strict-exit-codes` (opt-in): exit 3 when mutants timed out, 4 on
|
|
59
|
+
runtime/compile errors; precedence 2 > 3 > 4 > 1 > 0
|
|
60
|
+
- Auto-calibrated per-mutant timeout from the measured test baseline
|
|
61
|
+
(`--timeout-multiplier`, default 3.0) when `mutation.timeout` is unset
|
|
62
|
+
- Runtime-aware test ordering: per-test durations from the coverage pass
|
|
63
|
+
prioritize fast tests within a mutant run
|
|
64
|
+
- Richer `# henitai:disable` directives: operator lists, reasons, and
|
|
65
|
+
`begin`/`end` regions
|
|
66
|
+
- GitHub Actions annotation reporter
|
|
67
|
+
- `HENITAI_WORKER_SLOT` environment variable for per-worker resource
|
|
68
|
+
isolation in parallel runs
|
|
69
|
+
- `EqualityIdentityOperator`: the hard-to-kill `==`/`eql?`/`equal?` pairing
|
|
70
|
+
moved out of the default light set into its own operator (ADR-10)
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
- Mutants in `module_function` modules were unkillable: `module_function`
|
|
74
|
+
copies the method onto the module's singleton and activation only replaced
|
|
75
|
+
the instance side, so callers kept running original code and every such
|
|
76
|
+
mutant falsely survived. Activation now re-copies the injected method when
|
|
77
|
+
the pre-injection shape proves a `module_function` copy
|
|
78
|
+
- Per-test coverage attributed each line only to the first test that executed
|
|
79
|
+
it; the map now credits every test whose run increments a line's hit count,
|
|
80
|
+
fixing silent test under-selection (a latent false-Survived risk)
|
|
81
|
+
- Coverage freshness now watches the dependency file set as well: edits are
|
|
82
|
+
detected by mtime, deletions via a recorded path manifest
|
|
83
|
+
(`henitai_dependency_manifest.json`); `henitai clean` removes the sidecar
|
|
84
|
+
- Stable-id collisions between same-signature mutants are disambiguated with
|
|
85
|
+
a site offset (`legacyStableId` is emitted temporarily so scoped runs
|
|
86
|
+
replace pre-offset canonical entries instead of duplicating them)
|
|
87
|
+
- Child stdout/stderr capture is capped (`max_log_bytes`) so runaway mutants
|
|
88
|
+
cannot exhaust memory on long runs
|
|
89
|
+
- Flaky-retry counter no longer inflates when a respawned child fails again
|
|
90
|
+
|
|
91
|
+
### Changed
|
|
92
|
+
- Terminal reporter renders original nodes from their source slice and reuses
|
|
93
|
+
a memoized unparse of the mutated node shared with the JSON reporter
|
|
94
|
+
(a dogfood profile spent 27% of wall time re-unparsing survivors)
|
|
95
|
+
- The survivor dependency fingerprint prunes generated artifact trees
|
|
96
|
+
(fixture projects' `reports/`, `coverage/`, `mutation-logs/`) during the
|
|
97
|
+
scan — only on evidence of actual output, never by directory name alone —
|
|
98
|
+
cutting fingerprint time from ~0.7s to ~1.5ms and keeping smoke runs from
|
|
99
|
+
invalidating survivor reuse
|
|
100
|
+
- Mutant children no longer emit unconditional debug lines, coverage-formatter
|
|
101
|
+
warnings for deliberately suppressed coverage, or rspec-mocks `__send__`
|
|
102
|
+
redefinition warnings into their logs
|
|
103
|
+
|
|
104
|
+
### Internal
|
|
105
|
+
- Architecture documentation gained §8.11 (incremental verdict reuse) and
|
|
106
|
+
ADR-11 (content-fingerprint verdict reuse, not git scoping); new backlog
|
|
107
|
+
tickets for CI warm verdict cache, child null formatter, and auto-`--since`
|
|
108
|
+
- Spec writes into the repository checkout are rejected wholesale — mutants
|
|
109
|
+
that reroute CLI dispatch into `init` now count as kills instead of
|
|
110
|
+
littering the working tree
|
|
111
|
+
- `.henitai-pi-agent/` session transcripts untracked
|
|
112
|
+
|
|
10
113
|
## [0.2.1] - 2026-06-25
|
|
11
114
|
|
|
12
115
|
### Fixed
|
|
@@ -302,7 +405,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
302
405
|
- CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
|
|
303
406
|
- RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
|
|
304
407
|
|
|
305
|
-
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.
|
|
408
|
+
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.3.1...HEAD
|
|
409
|
+
[0.3.1]: https://github.com/martinotten/henitai/compare/v0.3.0...v0.3.1
|
|
410
|
+
[0.3.0]: https://github.com/martinotten/henitai/compare/v0.2.1...v0.3.0
|
|
306
411
|
[0.2.1]: https://github.com/martinotten/henitai/compare/v0.2.0...v0.2.1
|
|
307
412
|
[0.2.0]: https://github.com/martinotten/henitai/compare/v0.1.10...v0.2.0
|
|
308
413
|
[0.1.10]: https://github.com/martinotten/henitai/compare/v0.1.9...v0.1.10
|
data/README.md
CHANGED
|
@@ -51,7 +51,8 @@ gem install henitai
|
|
|
51
51
|
# Run mutation testing on the entire project
|
|
52
52
|
bundle exec henitai run
|
|
53
53
|
|
|
54
|
-
# Run only on subjects changed since main (CI-friendly)
|
|
54
|
+
# Run only on subjects changed since main (CI-friendly). Includes uncommitted
|
|
55
|
+
# working-tree changes; changed test files select the sources they cover.
|
|
55
56
|
bundle exec henitai run --since origin/main
|
|
56
57
|
|
|
57
58
|
# Run on a specific subject pattern
|
|
@@ -75,14 +76,28 @@ includes:
|
|
|
75
76
|
excludes:
|
|
76
77
|
- lib/henitai/eager_load.rb # standalone entry points with no in-process coverage
|
|
77
78
|
|
|
79
|
+
test_excludes:
|
|
80
|
+
- spec/end_to_end/*_spec.rb # never select these files for individual mutants
|
|
81
|
+
|
|
78
82
|
mutation:
|
|
79
83
|
operators: light # light | full
|
|
80
84
|
timeout: 10.0
|
|
81
85
|
max_flaky_retries: 3
|
|
86
|
+
max_log_bytes: 5000000
|
|
87
|
+
max_timeout: 30.0
|
|
82
88
|
sampling:
|
|
83
89
|
ratio: 0.05
|
|
84
90
|
strategy: stratified
|
|
85
91
|
reports_dir: reports
|
|
92
|
+
reporters:
|
|
93
|
+
- terminal
|
|
94
|
+
- json
|
|
95
|
+
- html
|
|
96
|
+
|
|
97
|
+
reports:
|
|
98
|
+
checkpoint: true
|
|
99
|
+
checkpoint_every: 200
|
|
100
|
+
checkpoint_interval: 30.0
|
|
86
101
|
|
|
87
102
|
thresholds:
|
|
88
103
|
high: 80
|
|
@@ -94,6 +109,15 @@ when a value is invalid.
|
|
|
94
109
|
|
|
95
110
|
CLI flags override the corresponding values from `.henitai.yml`.
|
|
96
111
|
|
|
112
|
+
`test_excludes` removes matching test files from per-mutant selection. A mutant
|
|
113
|
+
with no tests left is reported as `NoCoverage` and is not executed.
|
|
114
|
+
`mutation.max_log_bytes` is a positive per-stream byte cap for captured child
|
|
115
|
+
output. `mutation.max_timeout` caps auto-calibrated timeouts; an explicit
|
|
116
|
+
`mutation.timeout` still takes precedence. When JSON or HTML reporting is
|
|
117
|
+
enabled, `reports.checkpoint*` controls periodic canonical-report checkpoints.
|
|
118
|
+
Each checkpoint updates `mutation-report.json`; when the HTML reporter is
|
|
119
|
+
configured, it also regenerates `mutation-report.html` from that merged JSON.
|
|
120
|
+
|
|
97
121
|
Before mutation testing starts, Henitai checks whether the current coverage data
|
|
98
122
|
covers the configured source files. If not, Henitai runs the configured test
|
|
99
123
|
suite once to bootstrap a usable coverage baseline. If coverage is still
|
|
@@ -110,6 +134,16 @@ yet enable the per-test coverage formatter.
|
|
|
110
134
|
Henitai currently defaults to linear mutant execution. Set `jobs` in
|
|
111
135
|
`.henitai.yml` or pass `--jobs N` to opt into parallel mutant execution.
|
|
112
136
|
|
|
137
|
+
Every forked test child sees a `HENITAI_WORKER_SLOT` environment variable
|
|
138
|
+
holding a stable worker-slot index (`0..jobs-1`; always `0` on the linear
|
|
139
|
+
path). A flaky-retry respawn keeps the original attempt's value. Test suites
|
|
140
|
+
that touch shared external resources can isolate themselves per slot without
|
|
141
|
+
any henitai-side hooks — for example a per-worker database:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
database: "myapp_test_#{ENV.fetch('HENITAI_WORKER_SLOT', '0')}"
|
|
145
|
+
```
|
|
146
|
+
|
|
113
147
|
By default, Henitai keeps child test output out of the live terminal. Each
|
|
114
148
|
baseline or mutant run writes captured stdout/stderr to `reports/mutation-logs/`
|
|
115
149
|
and the terminal only shows progress plus a concise summary. Pass
|
|
@@ -117,7 +151,47 @@ and the terminal only shows progress plus a concise summary. Pass
|
|
|
117
151
|
|
|
118
152
|
`henitai version` prints the installed version. `henitai run` exits with `0`
|
|
119
153
|
when the mutation score meets the low threshold, `1` when it does not, and `2`
|
|
120
|
-
for framework errors.
|
|
154
|
+
for framework errors. With `--strict-exit-codes` (opt-in, additive) it also
|
|
155
|
+
exits `3` when one or more mutants timed out and `4` when runtime/compile
|
|
156
|
+
errors are present; precedence `2` > `3` > `4` > `1` > `0`. The timeout code
|
|
157
|
+
is informational — a run can pass its threshold and still exit `3`.
|
|
158
|
+
|
|
159
|
+
For pull-request feedback, add the `github` reporter to `reporters:` in
|
|
160
|
+
`.henitai.yml`: it prints one `::warning file=...,line=...` workflow command
|
|
161
|
+
per survived mutant, so survivors appear as inline annotations on the PR diff
|
|
162
|
+
in GitHub Actions. All other statuses stay silent.
|
|
163
|
+
|
|
164
|
+
`henitai run --incremental` reuses still-valid `Killed` and `Survived`
|
|
165
|
+
verdicts from the history store (`reports/mutation-history.sqlite3`) instead
|
|
166
|
+
of re-executing them. A `Killed` verdict is reused when the subject's source
|
|
167
|
+
and every covering test file are byte-identical to what was recorded. A
|
|
168
|
+
`Survived` verdict needs more proof, because a *new* test could now kill it:
|
|
169
|
+
it is reused only when, additionally, the live covering set derived from the
|
|
170
|
+
current per-test coverage map matches the recorded set exactly (no test
|
|
171
|
+
added, dropped, or changed) and the dependency fingerprint (spec helpers,
|
|
172
|
+
`spec/support`, fixtures/factories, `Gemfile.lock`, `.henitai.yml`, `.rspec`)
|
|
173
|
+
is unchanged. When per-test coverage is unavailable, survivors simply
|
|
174
|
+
re-execute — reuse never happens on doubt (see ADR-11).
|
|
175
|
+
Reused mutants stay visible in the report (`fromCache: true` beside
|
|
176
|
+
`stableId`) and count toward MS/MSI; the terminal prints
|
|
177
|
+
`N of M verdicts reused from history (K killed, S survived)` plus an
|
|
178
|
+
executed-only MS/MSI line so cached verdicts are never mistaken for fresh
|
|
179
|
+
results.
|
|
180
|
+
Generated mutants temporarily include `legacyStableId` so scoped runs replace
|
|
181
|
+
canonical entries written before site-offset IDs were introduced instead of
|
|
182
|
+
duplicating them.
|
|
183
|
+
Timeouts and errors always re-execute. `--force` bypasses reuse.
|
|
184
|
+
`henitai run --dry-run` lists the post-filter mutant set without executing
|
|
185
|
+
mutants and always exits `0`. Gate 0 may still run the configured test suite to
|
|
186
|
+
refresh baseline coverage.
|
|
187
|
+
|
|
188
|
+
Every `henitai run`, including a dry run, acquires a fail-fast advisory lock at
|
|
189
|
+
`<reports_dir>/.henitai-run.lock`. `henitai clean` uses the same lock, so it
|
|
190
|
+
cannot delete artifacts from a live run. Contention is a framework error (exit
|
|
191
|
+
code `2`); direct `Runner#run` callers receive `Henitai::ConcurrentRunError`.
|
|
192
|
+
The persistent JSON lock file records the last owner's PID and start time. It
|
|
193
|
+
is coordination metadata, not evidence of an active run: the kernel lock is
|
|
194
|
+
authoritative, and the file may be the only artifact a dry run creates.
|
|
121
195
|
|
|
122
196
|
`henitai run --survivors-from ...` performs a partial rerun: it reports only
|
|
123
197
|
the selected survivors, skips threshold-based exit checks, and does not update
|
|
@@ -125,6 +199,52 @@ the run trend history. Dirty worktree changes are included, so you can edit
|
|
|
125
199
|
tests locally without committing first; if source files under `includes` are
|
|
126
200
|
dirty, Henitai reruns the matched survivors conservatively.
|
|
127
201
|
|
|
202
|
+
### Skipping mutations inline
|
|
203
|
+
|
|
204
|
+
A `# henitai:disable` magic comment excludes code from mutation at the call
|
|
205
|
+
site, without touching `.henitai.yml`:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
def risky_calc(x)
|
|
209
|
+
x * 2 # henitai:disable — skips mutants on this line
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# henitai:disable — skips every mutant in this method
|
|
213
|
+
def legacy_shim(x)
|
|
214
|
+
x - 1
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The line form applies to mutants starting on that line. The method form is a
|
|
219
|
+
standalone comment in the contiguous comment block directly above a `def`
|
|
220
|
+
(a blank line breaks the association). Trailing text is allowed
|
|
221
|
+
(`# henitai:disable -- reviewed defensive branch`). Skipped mutants are
|
|
222
|
+
reported as `Ignored`, so they stay visible in the report instead of silently
|
|
223
|
+
vanishing. Use `mutation.ignore_patterns` for repo-wide policy and
|
|
224
|
+
`# henitai:disable` for one-off, reviewed exclusions.
|
|
225
|
+
|
|
226
|
+
Directives can also target specific operators, carry a reason (serialized as
|
|
227
|
+
`statusReason`, visible in the HTML report), and cover regions:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
x = a + b # henitai:disable ArithmeticOperator — only this operator
|
|
231
|
+
x = a + b # henitai:disable ArithmeticOperator: log noise — with a reason
|
|
232
|
+
|
|
233
|
+
# henitai:disable RegexMutator: timing-sensitive matcher
|
|
234
|
+
def parse(line)
|
|
235
|
+
...
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# henitai:disable-start ConditionalExpression
|
|
239
|
+
...region...
|
|
240
|
+
# henitai:disable-end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Operator names must exactly match the canonical names from
|
|
244
|
+
`henitai operator list`; unknown names, unmatched or nested
|
|
245
|
+
`disable-start`/`disable-end` directives abort the run (exit `2`) with the
|
|
246
|
+
offending file and line. Regions do not nest.
|
|
247
|
+
|
|
128
248
|
The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/workspaces/henitai/assets/schema/henitai.schema.json) for editor autocompletion.
|
|
129
249
|
|
|
130
250
|
## Operator sets
|
|
@@ -132,7 +252,7 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
|
|
|
132
252
|
**Light** (default) — high-signal, low-noise operators covering the majority of real-world defects:
|
|
133
253
|
|
|
134
254
|
- `ArithmeticOperator` — `+` ↔ `-`, `*` ↔ `/`
|
|
135
|
-
- `EqualityOperator` — `==` ↔ `!=`, `>` ↔ `<`, etc.
|
|
255
|
+
- `EqualityOperator` — `==` ↔ `!=`, `>` ↔ `<`, etc. (relational operators only)
|
|
136
256
|
- `LogicalOperator` — `&&` ↔ `||`
|
|
137
257
|
- `BooleanLiteral` — `true` ↔ `false`, `!expr`
|
|
138
258
|
- `ConditionalExpression` — remove branch bodies
|
|
@@ -151,6 +271,7 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
|
|
|
151
271
|
- `AssignmentExpression` — mutate compound assignment
|
|
152
272
|
- `UnaryOperator` — remove unary `-` and `~`
|
|
153
273
|
- `UpdateOperator` — swap compound assignments (`+=`↔`-=`, `*=`↔`/=`, `||=`↔`&&=`)
|
|
274
|
+
- `EqualityIdentityOperator` — `==` ↔ `eql?`/`equal?` (hardest equality pairing to kill; see [ADR-10](docs/architecture/adr/ADR-10-split-equality-identity-mutations.md))
|
|
154
275
|
|
|
155
276
|
## Stryker Dashboard integration
|
|
156
277
|
|
|
@@ -184,6 +305,7 @@ git clone https://github.com/martinotten/henitai
|
|
|
184
305
|
cd henitai
|
|
185
306
|
bundle install
|
|
186
307
|
bundle exec rspec # run tests
|
|
308
|
+
bundle exec ruby bin/verify-process-free-specs # verify self-mutation-safe specs
|
|
187
309
|
bundle exec rake smoke:integration:all # run rspec/minitest integration smoke projects
|
|
188
310
|
bundle exec rubocop # lint
|
|
189
311
|
bundle exec henitai clean # remove stale generated report artifacts
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
"type": "string"
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
|
+
"test_excludes": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
36
42
|
"mutation": {
|
|
37
43
|
"type": "object",
|
|
38
44
|
"additionalProperties": false,
|
|
@@ -43,10 +49,22 @@
|
|
|
43
49
|
"timeout": {
|
|
44
50
|
"type": "number"
|
|
45
51
|
},
|
|
52
|
+
"timeout_multiplier": {
|
|
53
|
+
"type": "number",
|
|
54
|
+
"exclusiveMinimum": 0
|
|
55
|
+
},
|
|
46
56
|
"max_flaky_retries": {
|
|
47
57
|
"type": "integer",
|
|
48
58
|
"minimum": 0
|
|
49
59
|
},
|
|
60
|
+
"max_log_bytes": {
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"exclusiveMinimum": 0
|
|
63
|
+
},
|
|
64
|
+
"max_timeout": {
|
|
65
|
+
"type": "number",
|
|
66
|
+
"exclusiveMinimum": 0
|
|
67
|
+
},
|
|
50
68
|
"ignore_patterns": {
|
|
51
69
|
"type": "array",
|
|
52
70
|
"items": {
|
|
@@ -106,6 +124,23 @@
|
|
|
106
124
|
"reports_dir": {
|
|
107
125
|
"type": "string"
|
|
108
126
|
},
|
|
127
|
+
"reports": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"additionalProperties": false,
|
|
130
|
+
"properties": {
|
|
131
|
+
"checkpoint": {
|
|
132
|
+
"type": "boolean"
|
|
133
|
+
},
|
|
134
|
+
"checkpoint_every": {
|
|
135
|
+
"type": "integer",
|
|
136
|
+
"exclusiveMinimum": 0
|
|
137
|
+
},
|
|
138
|
+
"checkpoint_interval": {
|
|
139
|
+
"type": "number",
|
|
140
|
+
"exclusiveMinimum": 0
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
109
144
|
"dashboard": {
|
|
110
145
|
"type": "object",
|
|
111
146
|
"additionalProperties": false,
|
|
@@ -5,6 +5,9 @@ require "etc"
|
|
|
5
5
|
module Henitai
|
|
6
6
|
# Detects the effective CPU count available to the current process.
|
|
7
7
|
class AvailableCpuCount
|
|
8
|
+
CFS_QUOTA_PATH = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us"
|
|
9
|
+
CFS_PERIOD_PATH = "/sys/fs/cgroup/cpu/cpu.cfs_period_us"
|
|
10
|
+
|
|
8
11
|
class << self
|
|
9
12
|
def detect
|
|
10
13
|
counts = [Etc.nprocessors, cgroup_cpu_quota, cpuset_cpu_count].compact.select(&:positive?)
|
|
@@ -14,38 +17,30 @@ module Henitai
|
|
|
14
17
|
private
|
|
15
18
|
|
|
16
19
|
def cgroup_cpu_quota
|
|
17
|
-
parse_cpu_max(read_limit("/sys/fs/cgroup/cpu.max"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
)
|
|
20
|
+
quota = parse_cpu_max(read_limit("/sys/fs/cgroup/cpu.max"))
|
|
21
|
+
return quota if quota
|
|
22
|
+
|
|
23
|
+
quota_count(read_limit(CFS_QUOTA_PATH), read_limit(CFS_PERIOD_PATH))
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def cpuset_cpu_count
|
|
25
|
-
count_cpu_list(read_limit("/sys/fs/cgroup/cpuset.cpus.effective"))
|
|
26
|
-
|
|
27
|
+
count = count_cpu_list(read_limit("/sys/fs/cgroup/cpuset.cpus.effective"))
|
|
28
|
+
return count if count
|
|
29
|
+
|
|
30
|
+
count_cpu_list(read_limit("/sys/fs/cgroup/cpuset.cpus"))
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def read_limit(path)
|
|
30
34
|
return unless File.file?(path)
|
|
31
35
|
|
|
32
|
-
File.read(path)
|
|
33
|
-
rescue Errno::ENOENT, Errno::EACCES
|
|
34
|
-
nil
|
|
36
|
+
File.read(path)
|
|
37
|
+
rescue Errno::ENOENT, Errno::EACCES then nil
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def parse_cpu_max(value)
|
|
38
|
-
return if value.nil?
|
|
41
|
+
return if value.nil?
|
|
39
42
|
|
|
40
43
|
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
44
|
quota_count(quota, period)
|
|
50
45
|
end
|
|
51
46
|
|
|
@@ -55,16 +50,14 @@ module Henitai
|
|
|
55
50
|
return if quota_value <= 0 || period_value <= 0
|
|
56
51
|
|
|
57
52
|
[quota_value / period_value, 1].max
|
|
58
|
-
rescue ArgumentError
|
|
59
|
-
nil
|
|
53
|
+
rescue ArgumentError then nil
|
|
60
54
|
end
|
|
61
55
|
|
|
62
56
|
def count_cpu_list(value)
|
|
63
57
|
return if value.nil? || value.empty?
|
|
64
58
|
|
|
65
59
|
value.split(",").sum { |entry| cpu_list_entry_size(entry) }
|
|
66
|
-
rescue ArgumentError
|
|
67
|
-
nil
|
|
60
|
+
rescue ArgumentError then nil
|
|
68
61
|
end
|
|
69
62
|
|
|
70
63
|
def cpu_list_entry_size(entry)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Merges a scoped/partial run's Stryker schema into the previously-written
|
|
7
|
+
# canonical report on disk instead of letting it fully replace the file.
|
|
8
|
+
#
|
|
9
|
+
# Pure and fail-safe: any anomaly (missing/corrupt prior file, mismatched
|
|
10
|
+
# shape, a merge that would end up thinner than the current run alone)
|
|
11
|
+
# falls back to the current run's schema by itself -- structurally never
|
|
12
|
+
# worse than the unconditional overwrite this replaces.
|
|
13
|
+
#
|
|
14
|
+
# Merges at mutant granularity, keyed by `stableId`, not file granularity:
|
|
15
|
+
# a `--survivors-from` rerun's schema only contains the re-verified
|
|
16
|
+
# survivors for a file, so replacing that file's whole entry would drop
|
|
17
|
+
# every other mutant (e.g. Killed ones) in the same file.
|
|
18
|
+
module CanonicalReportMerger
|
|
19
|
+
# @param prune_missing [Boolean] when true, prior file entries whose source
|
|
20
|
+
# file no longer exists on disk are dropped from the merged report (a
|
|
21
|
+
# deleted module, or one removed from `includes`). Off by default so the
|
|
22
|
+
# merge stays a pure schema operation; the reporter enables it because it
|
|
23
|
+
# runs from the project root where relative source paths resolve.
|
|
24
|
+
def self.merge(current_schema, prior_path, prune_missing: false)
|
|
25
|
+
current = stringify(current_schema)
|
|
26
|
+
return current unless File.exist?(prior_path)
|
|
27
|
+
|
|
28
|
+
prior = JSON.parse(File.read(prior_path))
|
|
29
|
+
return current unless prior.is_a?(Hash) && prior["files"].is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
merged = merge_files(current, prior, prune_missing:)
|
|
32
|
+
return current unless safe?(merged, current)
|
|
33
|
+
|
|
34
|
+
merged
|
|
35
|
+
rescue StandardError
|
|
36
|
+
stringify(current_schema)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.merge_files(current, prior, prune_missing:)
|
|
40
|
+
merged_files = overlay_current_mutants(deep_dup(prior["files"]), current)
|
|
41
|
+
merged_files.reject! { |_, file| file["mutants"].empty? }
|
|
42
|
+
prune_missing_source_files(merged_files) if prune_missing
|
|
43
|
+
current.merge("files" => merged_files)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :merge_files
|
|
46
|
+
|
|
47
|
+
# Drops entries carried over from the prior report whose source file no
|
|
48
|
+
# longer exists on disk (deleted module, or dropped from `includes`). Files
|
|
49
|
+
# produced by the current run always exist, so a scoped rerun never removes
|
|
50
|
+
# in-scope-but-untouched findings -- only genuinely gone paths.
|
|
51
|
+
def self.prune_missing_source_files(merged_files)
|
|
52
|
+
merged_files.select! { |file, _| File.exist?(file) }
|
|
53
|
+
end
|
|
54
|
+
private_class_method :prune_missing_source_files
|
|
55
|
+
|
|
56
|
+
def self.overlay_current_mutants(merged_files, current)
|
|
57
|
+
strip_rerun_mutants(merged_files, mutant_ids(current))
|
|
58
|
+
append_current_mutants(merged_files, current)
|
|
59
|
+
merged_files
|
|
60
|
+
end
|
|
61
|
+
private_class_method :overlay_current_mutants
|
|
62
|
+
|
|
63
|
+
def self.strip_rerun_mutants(merged_files, current_ids)
|
|
64
|
+
merged_files.each_value { |file| file["mutants"].reject! { |m| current_ids.include?(m["stableId"]) } }
|
|
65
|
+
end
|
|
66
|
+
private_class_method :strip_rerun_mutants
|
|
67
|
+
|
|
68
|
+
def self.append_current_mutants(merged_files, current)
|
|
69
|
+
current.fetch("files", {}).each do |file, entry|
|
|
70
|
+
target = merged_files[file] ||= { "mutants" => [] }
|
|
71
|
+
target["source"] = entry["source"]
|
|
72
|
+
target["language"] = entry["language"]
|
|
73
|
+
target["mutants"] += entry["mutants"]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
private_class_method :append_current_mutants
|
|
77
|
+
|
|
78
|
+
def self.mutant_ids(schema)
|
|
79
|
+
schema.fetch("files", {}).each_value.flat_map do |file|
|
|
80
|
+
file.fetch("mutants", []).flat_map do |mutant|
|
|
81
|
+
[mutant["stableId"], mutant["legacyStableId"]].compact
|
|
82
|
+
end
|
|
83
|
+
end.to_set
|
|
84
|
+
end
|
|
85
|
+
private_class_method :mutant_ids
|
|
86
|
+
|
|
87
|
+
def self.safe?(merged, current)
|
|
88
|
+
mutant_count(merged) >= mutant_count(current)
|
|
89
|
+
end
|
|
90
|
+
private_class_method :safe?
|
|
91
|
+
|
|
92
|
+
def self.mutant_count(schema)
|
|
93
|
+
schema.fetch("files", {}).values.sum { |file| file.fetch("mutants", []).size }
|
|
94
|
+
end
|
|
95
|
+
private_class_method :mutant_count
|
|
96
|
+
|
|
97
|
+
def self.deep_dup(object)
|
|
98
|
+
Marshal.load(Marshal.dump(object))
|
|
99
|
+
end
|
|
100
|
+
private_class_method :deep_dup
|
|
101
|
+
|
|
102
|
+
def self.stringify(schema)
|
|
103
|
+
JSON.parse(JSON.generate(schema))
|
|
104
|
+
end
|
|
105
|
+
private_class_method :stringify
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
# Writes a Stryker schema to the canonical report path.
|
|
8
|
+
#
|
|
9
|
+
# Authoritative (full-run) writes replace the file wholesale; non-authoritative
|
|
10
|
+
# writes (scoped/partial runs, and every incremental checkpoint after the
|
|
11
|
+
# first) merge into the existing report via {CanonicalReportMerger}, pruning
|
|
12
|
+
# entries whose source file no longer exists. Shared by the JSON reporter and
|
|
13
|
+
# the {CheckpointReporter} so both persist the report identically.
|
|
14
|
+
module CanonicalReportWriter
|
|
15
|
+
def self.write(schema, path:, authoritative:)
|
|
16
|
+
output = authoritative ? schema : CanonicalReportMerger.merge(schema, path, prune_missing: true)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
18
|
+
File.write(path, JSON.pretty_generate(output))
|
|
19
|
+
output
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Progress observer that flushes the canonical report to disk periodically
|
|
5
|
+
# during a run, so a crash on a long run keeps partial results and the report
|
|
6
|
+
# grows visibly mid-run instead of appearing only at the end (Gate 5).
|
|
7
|
+
#
|
|
8
|
+
# Each flush serialises only the mutants completed since the previous flush
|
|
9
|
+
# and folds them into the on-disk JSON report. When HTML reporting is active,
|
|
10
|
+
# the self-contained HTML report is regenerated from that merged JSON schema.
|
|
11
|
+
# Reuses {Result#to_stryker_schema} and {CanonicalReportWriter}.
|
|
12
|
+
#
|
|
13
|
+
# On a full (authoritative) run the first flush replaces the report — wiping
|
|
14
|
+
# entries left by earlier runs, including deleted files — and later flushes
|
|
15
|
+
# merge their batch in. On a scoped/partial run every flush merges, matching
|
|
16
|
+
# the end-of-run reporter's behaviour.
|
|
17
|
+
class CheckpointReporter
|
|
18
|
+
MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
19
|
+
|
|
20
|
+
def initialize(config:, source_provider:, authoritative:, started_at: Time.now, clock: MONOTONIC)
|
|
21
|
+
@config = config
|
|
22
|
+
@source_provider = source_provider
|
|
23
|
+
@authoritative = authoritative
|
|
24
|
+
@started_at = started_at
|
|
25
|
+
@clock = clock
|
|
26
|
+
@batch = []
|
|
27
|
+
@last_flush_at = clock.call
|
|
28
|
+
@flushed = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def progress(mutant, **)
|
|
32
|
+
@batch << mutant
|
|
33
|
+
flush! if due?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def due?
|
|
39
|
+
@batch.size >= @config.checkpoint_every ||
|
|
40
|
+
(@clock.call - @last_flush_at) >= @config.checkpoint_interval
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def flush!
|
|
44
|
+
first_full_flush = @authoritative && !@flushed
|
|
45
|
+
merged_schema = CanonicalReportWriter.write(
|
|
46
|
+
build_schema(@batch),
|
|
47
|
+
path: canonical_path,
|
|
48
|
+
authoritative: first_full_flush
|
|
49
|
+
)
|
|
50
|
+
write_html(merged_schema) if html_reporter?
|
|
51
|
+
@flushed = true
|
|
52
|
+
@batch = []
|
|
53
|
+
@last_flush_at = @clock.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_schema(mutants)
|
|
57
|
+
Result.new(
|
|
58
|
+
mutants: mutants,
|
|
59
|
+
started_at: @started_at,
|
|
60
|
+
finished_at: Time.now,
|
|
61
|
+
thresholds: @config.thresholds,
|
|
62
|
+
source_provider: @source_provider,
|
|
63
|
+
authoritative: @authoritative
|
|
64
|
+
).to_stryker_schema
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def canonical_path
|
|
68
|
+
File.join(@config.reports_dir, "mutation-report.json")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def html_reporter?
|
|
72
|
+
Array(@config.reporters).map(&:to_s).include?("html")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_html(schema)
|
|
76
|
+
Reporter::Html.new(config: @config).report_schema(schema)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|