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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +125 -3
  4. data/assets/schema/henitai.schema.json +35 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +67 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +81 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +248 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +75 -10
  57. data/lib/henitai/runner.rb +102 -43
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +235 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cda194514bf677d142e24c97c1589714041d2c88c4d2b33ffaf642e79c7ebe5d
4
- data.tar.gz: 8505d5d6add5b850c5accb4904f43351e129b9261699c58051c35faa1a4b3758
3
+ metadata.gz: 425e1946a343ce94a9172939a8ad5c5d49e01fe86fbf2b01326b2f0be068df85
4
+ data.tar.gz: 85867b98b26cb3710d0c1e56bd4b37b5ba78f08f47ad8378dc76ae07ce1dfc9a
5
5
  SHA512:
6
- metadata.gz: b17e21c51f0d4aac03154d8b782d4e361ea8fa792c03a984b9d5b94deccd2411ffb3fe8a7e6fe096b76b33f2bc211a813618c059642b4dec3e54b8fab4d01d8a
7
- data.tar.gz: 109527f73b390086f03b5249dd90e97bf57ad3e34551f94d1f80b8f1e5968699e8d6cefe394ebf87bc10465b2a759de737d5f54257807e654a92cd040fa73f59
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.2.1...HEAD
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
- parse_cpu_cfs(
19
- read_limit("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"),
20
- read_limit("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
21
- )
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
- count_cpu_list(read_limit("/sys/fs/cgroup/cpuset.cpus"))
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).strip
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? || value.empty?
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