henitai 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +123 -2
  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 +64 -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 +66 -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 +238 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +72 -9
  57. data/lib/henitai/runner.rb +82 -40
  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 +230 -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: 49d5bc19a4c6d8335797de16d71488d9ffdbb5b85c03b541da1f99a5ba39a009
4
+ data.tar.gz: bb6490909c3c87cbe815ed960b649e23d60850ee41bb019097f6a4772de777ab
5
5
  SHA512:
6
- metadata.gz: b17e21c51f0d4aac03154d8b782d4e361ea8fa792c03a984b9d5b94deccd2411ffb3fe8a7e6fe096b76b33f2bc211a813618c059642b4dec3e54b8fab4d01d8a
7
- data.tar.gz: 109527f73b390086f03b5249dd90e97bf57ad3e34551f94d1f80b8f1e5968699e8d6cefe394ebf87bc10465b2a759de737d5f54257807e654a92cd040fa73f59
6
+ metadata.gz: 10d2c66aa8f8ca9ecd64a482d4bc73880f1598e3174a9df220201874333eab2d846903560dec5270fed4291b401a58f7a41b0e28fcafd5918fbd631bfb77e70b
7
+ data.tar.gz: d2c3859f3c70d28032a7a18b859df7713d6e39ca265004c8be86fe0d15ec450aea2e0bf2a60b147e6d2098c6c1f436829bdef87e6ff68353782790c9419465d6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-13
11
+
12
+ ### Added
13
+ - `henitai run --incremental`: verdict reuse from the history store. Killed
14
+ verdicts are reused when the subject's source and every covering test file
15
+ are byte-identical to what was recorded; Survived verdicts are additionally
16
+ gated on the live per-test covering set (membership and content) plus a
17
+ run-level dependency fingerprint over spec helpers, support files,
18
+ fixtures/factories, `Gemfile.lock`, `.henitai.yml`, and `.rspec` (ADR-11).
19
+ Reused mutants carry `fromCache: true` in the JSON report; the terminal
20
+ prints the reused split and an executed-only MS/MSI line. `--force` bypasses
21
+ reuse; every doubt (missing per-test map, ambiguous ids, legacy rows,
22
+ timeout/error verdicts) resolves to re-execution
23
+ - Canonical report merge: scoped and partial runs (`--since`, subject
24
+ patterns, `--survivors-from`) merge into `reports/mutation-report.json`
25
+ keyed by `stableId` instead of overwriting it; full runs still replace it
26
+ - Checkpoint reporting: long runs persist partial JSON/HTML results
27
+ incrementally, so a crashed or aborted run keeps its finished verdicts
28
+ - Reports-directory locking: concurrent runs against the same `reports_dir`
29
+ fail fast with `ConcurrentRunError`; when the recorded owner pid is dead the
30
+ error names the likely orphaned child and the `lsof` command to find it
31
+ - `henitai run --dry-run`: list the post-filter mutant set without executing
32
+ any tests (always exits 0)
33
+ - `--strict-exit-codes` (opt-in): exit 3 when mutants timed out, 4 on
34
+ runtime/compile errors; precedence 2 > 3 > 4 > 1 > 0
35
+ - Auto-calibrated per-mutant timeout from the measured test baseline
36
+ (`--timeout-multiplier`, default 3.0) when `mutation.timeout` is unset
37
+ - Runtime-aware test ordering: per-test durations from the coverage pass
38
+ prioritize fast tests within a mutant run
39
+ - Richer `# henitai:disable` directives: operator lists, reasons, and
40
+ `begin`/`end` regions
41
+ - GitHub Actions annotation reporter
42
+ - `HENITAI_WORKER_SLOT` environment variable for per-worker resource
43
+ isolation in parallel runs
44
+ - `EqualityIdentityOperator`: the hard-to-kill `==`/`eql?`/`equal?` pairing
45
+ moved out of the default light set into its own operator (ADR-10)
46
+
47
+ ### Fixed
48
+ - Mutants in `module_function` modules were unkillable: `module_function`
49
+ copies the method onto the module's singleton and activation only replaced
50
+ the instance side, so callers kept running original code and every such
51
+ mutant falsely survived. Activation now re-copies the injected method when
52
+ the pre-injection shape proves a `module_function` copy
53
+ - Per-test coverage attributed each line only to the first test that executed
54
+ it; the map now credits every test whose run increments a line's hit count,
55
+ fixing silent test under-selection (a latent false-Survived risk)
56
+ - Coverage freshness now watches the dependency file set as well: edits are
57
+ detected by mtime, deletions via a recorded path manifest
58
+ (`henitai_dependency_manifest.json`); `henitai clean` removes the sidecar
59
+ - Stable-id collisions between same-signature mutants are disambiguated with
60
+ a site offset (`legacyStableId` is emitted temporarily so scoped runs
61
+ replace pre-offset canonical entries instead of duplicating them)
62
+ - Child stdout/stderr capture is capped (`max_log_bytes`) so runaway mutants
63
+ cannot exhaust memory on long runs
64
+ - Flaky-retry counter no longer inflates when a respawned child fails again
65
+
66
+ ### Changed
67
+ - Terminal reporter renders original nodes from their source slice and reuses
68
+ a memoized unparse of the mutated node shared with the JSON reporter
69
+ (a dogfood profile spent 27% of wall time re-unparsing survivors)
70
+ - The survivor dependency fingerprint prunes generated artifact trees
71
+ (fixture projects' `reports/`, `coverage/`, `mutation-logs/`) during the
72
+ scan — only on evidence of actual output, never by directory name alone —
73
+ cutting fingerprint time from ~0.7s to ~1.5ms and keeping smoke runs from
74
+ invalidating survivor reuse
75
+ - Mutant children no longer emit unconditional debug lines, coverage-formatter
76
+ warnings for deliberately suppressed coverage, or rspec-mocks `__send__`
77
+ redefinition warnings into their logs
78
+
79
+ ### Internal
80
+ - Architecture documentation gained §8.11 (incremental verdict reuse) and
81
+ ADR-11 (content-fingerprint verdict reuse, not git scoping); new backlog
82
+ tickets for CI warm verdict cache, child null formatter, and auto-`--since`
83
+ - Spec writes into the repository checkout are rejected wholesale — mutants
84
+ that reroute CLI dispatch into `init` now count as kills instead of
85
+ littering the working tree
86
+ - `.henitai-pi-agent/` session transcripts untracked
87
+
10
88
  ## [0.2.1] - 2026-06-25
11
89
 
12
90
  ### Fixed
@@ -302,7 +380,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
302
380
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
303
381
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
304
382
 
305
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.2.1...HEAD
383
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.3.0...HEAD
384
+ [0.3.0]: https://github.com/martinotten/henitai/compare/v0.2.1...v0.3.0
306
385
  [0.2.1]: https://github.com/martinotten/henitai/compare/v0.2.0...v0.2.1
307
386
  [0.2.0]: https://github.com/martinotten/henitai/compare/v0.1.10...v0.2.0
308
387
  [0.1.10]: https://github.com/martinotten/henitai/compare/v0.1.9...v0.1.10
data/README.md CHANGED
@@ -75,14 +75,28 @@ includes:
75
75
  excludes:
76
76
  - lib/henitai/eager_load.rb # standalone entry points with no in-process coverage
77
77
 
78
+ test_excludes:
79
+ - spec/end_to_end/*_spec.rb # never select these files for individual mutants
80
+
78
81
  mutation:
79
82
  operators: light # light | full
80
83
  timeout: 10.0
81
84
  max_flaky_retries: 3
85
+ max_log_bytes: 5000000
86
+ max_timeout: 30.0
82
87
  sampling:
83
88
  ratio: 0.05
84
89
  strategy: stratified
85
90
  reports_dir: reports
91
+ reporters:
92
+ - terminal
93
+ - json
94
+ - html
95
+
96
+ reports:
97
+ checkpoint: true
98
+ checkpoint_every: 200
99
+ checkpoint_interval: 30.0
86
100
 
87
101
  thresholds:
88
102
  high: 80
@@ -94,6 +108,15 @@ when a value is invalid.
94
108
 
95
109
  CLI flags override the corresponding values from `.henitai.yml`.
96
110
 
111
+ `test_excludes` removes matching test files from per-mutant selection. A mutant
112
+ with no tests left is reported as `NoCoverage` and is not executed.
113
+ `mutation.max_log_bytes` is a positive per-stream byte cap for captured child
114
+ output. `mutation.max_timeout` caps auto-calibrated timeouts; an explicit
115
+ `mutation.timeout` still takes precedence. When JSON or HTML reporting is
116
+ enabled, `reports.checkpoint*` controls periodic canonical-report checkpoints.
117
+ Each checkpoint updates `mutation-report.json`; when the HTML reporter is
118
+ configured, it also regenerates `mutation-report.html` from that merged JSON.
119
+
97
120
  Before mutation testing starts, Henitai checks whether the current coverage data
98
121
  covers the configured source files. If not, Henitai runs the configured test
99
122
  suite once to bootstrap a usable coverage baseline. If coverage is still
@@ -110,6 +133,16 @@ yet enable the per-test coverage formatter.
110
133
  Henitai currently defaults to linear mutant execution. Set `jobs` in
111
134
  `.henitai.yml` or pass `--jobs N` to opt into parallel mutant execution.
112
135
 
136
+ Every forked test child sees a `HENITAI_WORKER_SLOT` environment variable
137
+ holding a stable worker-slot index (`0..jobs-1`; always `0` on the linear
138
+ path). A flaky-retry respawn keeps the original attempt's value. Test suites
139
+ that touch shared external resources can isolate themselves per slot without
140
+ any henitai-side hooks — for example a per-worker database:
141
+
142
+ ```ruby
143
+ database: "myapp_test_#{ENV.fetch('HENITAI_WORKER_SLOT', '0')}"
144
+ ```
145
+
113
146
  By default, Henitai keeps child test output out of the live terminal. Each
114
147
  baseline or mutant run writes captured stdout/stderr to `reports/mutation-logs/`
115
148
  and the terminal only shows progress plus a concise summary. Pass
@@ -117,7 +150,47 @@ and the terminal only shows progress plus a concise summary. Pass
117
150
 
118
151
  `henitai version` prints the installed version. `henitai run` exits with `0`
119
152
  when the mutation score meets the low threshold, `1` when it does not, and `2`
120
- for framework errors.
153
+ for framework errors. With `--strict-exit-codes` (opt-in, additive) it also
154
+ exits `3` when one or more mutants timed out and `4` when runtime/compile
155
+ errors are present; precedence `2` > `3` > `4` > `1` > `0`. The timeout code
156
+ is informational — a run can pass its threshold and still exit `3`.
157
+
158
+ For pull-request feedback, add the `github` reporter to `reporters:` in
159
+ `.henitai.yml`: it prints one `::warning file=...,line=...` workflow command
160
+ per survived mutant, so survivors appear as inline annotations on the PR diff
161
+ in GitHub Actions. All other statuses stay silent.
162
+
163
+ `henitai run --incremental` reuses still-valid `Killed` and `Survived`
164
+ verdicts from the history store (`reports/mutation-history.sqlite3`) instead
165
+ of re-executing them. A `Killed` verdict is reused when the subject's source
166
+ and every covering test file are byte-identical to what was recorded. A
167
+ `Survived` verdict needs more proof, because a *new* test could now kill it:
168
+ it is reused only when, additionally, the live covering set derived from the
169
+ current per-test coverage map matches the recorded set exactly (no test
170
+ added, dropped, or changed) and the dependency fingerprint (spec helpers,
171
+ `spec/support`, fixtures/factories, `Gemfile.lock`, `.henitai.yml`, `.rspec`)
172
+ is unchanged. When per-test coverage is unavailable, survivors simply
173
+ re-execute — reuse never happens on doubt (see ADR-11).
174
+ Reused mutants stay visible in the report (`fromCache: true` beside
175
+ `stableId`) and count toward MS/MSI; the terminal prints
176
+ `N of M verdicts reused from history (K killed, S survived)` plus an
177
+ executed-only MS/MSI line so cached verdicts are never mistaken for fresh
178
+ results.
179
+ Generated mutants temporarily include `legacyStableId` so scoped runs replace
180
+ canonical entries written before site-offset IDs were introduced instead of
181
+ duplicating them.
182
+ Timeouts and errors always re-execute. `--force` bypasses reuse.
183
+ `henitai run --dry-run` lists the post-filter mutant set without executing
184
+ mutants and always exits `0`. Gate 0 may still run the configured test suite to
185
+ refresh baseline coverage.
186
+
187
+ Every `henitai run`, including a dry run, acquires a fail-fast advisory lock at
188
+ `<reports_dir>/.henitai-run.lock`. `henitai clean` uses the same lock, so it
189
+ cannot delete artifacts from a live run. Contention is a framework error (exit
190
+ code `2`); direct `Runner#run` callers receive `Henitai::ConcurrentRunError`.
191
+ The persistent JSON lock file records the last owner's PID and start time. It
192
+ is coordination metadata, not evidence of an active run: the kernel lock is
193
+ authoritative, and the file may be the only artifact a dry run creates.
121
194
 
122
195
  `henitai run --survivors-from ...` performs a partial rerun: it reports only
123
196
  the selected survivors, skips threshold-based exit checks, and does not update
@@ -125,6 +198,52 @@ the run trend history. Dirty worktree changes are included, so you can edit
125
198
  tests locally without committing first; if source files under `includes` are
126
199
  dirty, Henitai reruns the matched survivors conservatively.
127
200
 
201
+ ### Skipping mutations inline
202
+
203
+ A `# henitai:disable` magic comment excludes code from mutation at the call
204
+ site, without touching `.henitai.yml`:
205
+
206
+ ```ruby
207
+ def risky_calc(x)
208
+ x * 2 # henitai:disable — skips mutants on this line
209
+ end
210
+
211
+ # henitai:disable — skips every mutant in this method
212
+ def legacy_shim(x)
213
+ x - 1
214
+ end
215
+ ```
216
+
217
+ The line form applies to mutants starting on that line. The method form is a
218
+ standalone comment in the contiguous comment block directly above a `def`
219
+ (a blank line breaks the association). Trailing text is allowed
220
+ (`# henitai:disable -- reviewed defensive branch`). Skipped mutants are
221
+ reported as `Ignored`, so they stay visible in the report instead of silently
222
+ vanishing. Use `mutation.ignore_patterns` for repo-wide policy and
223
+ `# henitai:disable` for one-off, reviewed exclusions.
224
+
225
+ Directives can also target specific operators, carry a reason (serialized as
226
+ `statusReason`, visible in the HTML report), and cover regions:
227
+
228
+ ```ruby
229
+ x = a + b # henitai:disable ArithmeticOperator — only this operator
230
+ x = a + b # henitai:disable ArithmeticOperator: log noise — with a reason
231
+
232
+ # henitai:disable RegexMutator: timing-sensitive matcher
233
+ def parse(line)
234
+ ...
235
+ end
236
+
237
+ # henitai:disable-start ConditionalExpression
238
+ ...region...
239
+ # henitai:disable-end
240
+ ```
241
+
242
+ Operator names must exactly match the canonical names from
243
+ `henitai operator list`; unknown names, unmatched or nested
244
+ `disable-start`/`disable-end` directives abort the run (exit `2`) with the
245
+ offending file and line. Regions do not nest.
246
+
128
247
  The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/workspaces/henitai/assets/schema/henitai.schema.json) for editor autocompletion.
129
248
 
130
249
  ## Operator sets
@@ -132,7 +251,7 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
132
251
  **Light** (default) — high-signal, low-noise operators covering the majority of real-world defects:
133
252
 
134
253
  - `ArithmeticOperator` — `+` ↔ `-`, `*` ↔ `/`
135
- - `EqualityOperator` — `==` ↔ `!=`, `>` ↔ `<`, etc.
254
+ - `EqualityOperator` — `==` ↔ `!=`, `>` ↔ `<`, etc. (relational operators only)
136
255
  - `LogicalOperator` — `&&` ↔ `||`
137
256
  - `BooleanLiteral` — `true` ↔ `false`, `!expr`
138
257
  - `ConditionalExpression` — remove branch bodies
@@ -151,6 +270,7 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
151
270
  - `AssignmentExpression` — mutate compound assignment
152
271
  - `UnaryOperator` — remove unary `-` and `~`
153
272
  - `UpdateOperator` — swap compound assignments (`+=`↔`-=`, `*=`↔`/=`, `||=`↔`&&=`)
273
+ - `EqualityIdentityOperator` — `==` ↔ `eql?`/`equal?` (hardest equality pairing to kill; see [ADR-10](docs/architecture/adr/ADR-10-split-equality-identity-mutations.md))
154
274
 
155
275
  ## Stryker Dashboard integration
156
276
 
@@ -184,6 +304,7 @@ git clone https://github.com/martinotten/henitai
184
304
  cd henitai
185
305
  bundle install
186
306
  bundle exec rspec # run tests
307
+ bundle exec ruby bin/verify-process-free-specs # verify self-mutation-safe specs
187
308
  bundle exec rake smoke:integration:all # run rspec/minitest integration smoke projects
188
309
  bundle exec rubocop # lint
189
310
  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
@@ -16,22 +16,28 @@ module Henitai
16
16
  return if @command_halted
17
17
 
18
18
  config = load_config(options)
19
- removed_paths = cleanup_report_artifacts(config)
19
+ removed_paths = ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
20
+ cleanup_report_artifacts(config)
21
+ end
20
22
  puts clean_summary(removed_paths)
21
23
  rescue StandardError => e
22
24
  handle_run_error(e)
23
25
  end
24
26
 
25
27
  def cleanup_report_artifacts(config)
26
- removed_paths = report_cleanup_paths(config).select { |path| File.exist?(path) }
27
- removed_paths.each { |path| FileUtils.rm_f(path) }
28
- removed_paths
28
+ removed_dirs = cleanup_dir_paths(config).select { |path| File.directory?(path) }
29
+ removed_dirs.each { |path| FileUtils.rm_rf(path) }
30
+ removed_files = cleanup_file_paths(config).select { |path| File.file?(path) }
31
+ removed_files.each { |path| FileUtils.rm_f(path) }
32
+ removed_dirs + removed_files
29
33
  end
30
34
 
31
- def report_cleanup_paths(config)
32
- REPORT_CLEANUP_PATHS.map do |relative_path|
33
- File.join(config.reports_dir, *relative_path)
34
- end
35
+ def cleanup_file_paths(config)
36
+ REPORT_CLEANUP_PATHS.map { |relative_path| File.join(config.reports_dir, *relative_path) }
37
+ end
38
+
39
+ def cleanup_dir_paths(config)
40
+ REPORT_CLEANUP_DIRS.map { |relative_path| File.join(config.reports_dir, *relative_path) }
35
41
  end
36
42
 
37
43
  def clean_summary(removed_paths)