henitai 0.5.3 → 0.6.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -1
  3. data/README.md +100 -13
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +36 -10
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +7 -5
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +225 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0530ecd98105a394b4466cbe23704f848565c55e318c35a31e096eedc2cba299
4
- data.tar.gz: 56c64c5b679b0bc7c66691a4a7b20c274ac90d1d812d7abb65e778190cd6bb39
3
+ metadata.gz: 4c9ef6e2d806e8d12acfd36adda062192c5cf4ba700ee140ce7d6ad848eff0c8
4
+ data.tar.gz: 800ba49ba72ce0ab6090e1ab9b40ea5146559738e59af5a81ceeb80923bc974e
5
5
  SHA512:
6
- metadata.gz: e8725233ea8c806ca65fc488560daafbfc2300f723a054039b29dd45625a935217ebdbe609fde3e2df693299ee5ca069d35ce00b63a4e9a4d90dcfa9e77e2920
7
- data.tar.gz: d8052c091f3967d1cc873cb34b02f04ad339714f08950841f65e7ef1ff064573bb0f54226eb3725d29dd3e7a42727d535b469396c76aa0a0ae2ffb5ae1075336
6
+ metadata.gz: 60acc4fc6df82c4d273c110302af6a73b7c4f7d6f3ebb2ae9fdcea06ca69e5cef2e61945c396b38810f978b8ace3db2b988d224b637d0f8f11c02ccb03eee368
7
+ data.tar.gz: 9fe40adf6368041348247af329f3153a0027e89bbbe60e9dcdef27a4a666512446ab7569188e891ec7eb2f5fe8419c33eeee2ed1778a5b034c97901ca94a095c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,226 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-20
11
+
12
+ Minor release with a deliberate score break: false kills and harness
13
+ failures no longer count as detected mutants, so most projects, including
14
+ Henitai's own dogfood run, will report lower Mutation Scores. Re-measure
15
+ before adjusting thresholds.
16
+
17
+ ### Changed
18
+
19
+ - **Scores will drop, by design.** A mutant now counts as detected only when a
20
+ test failed because of its mutation (ADR-14). Harness failures that used to
21
+ be credited as kills are now reported as `CompileError` or `RuntimeError`,
22
+ with the reason in `statusReason`. On an assertion-free test suite, 0.5.3
23
+ reported 130 of 137 mutants as killed; this release reports none. Projects
24
+ should expect lower Mutation Scores. Re-measure before adjusting thresholds.
25
+ - **Flaky-test handling.** Survivors are no longer retried: a retry could only
26
+ turn a survivor into a kill and inflate the score. Kills are re-checked, up
27
+ to `max_flaky_retries`, only when every failing test file is known to be
28
+ flaky, as found by the new opt-in `mutation.flaky_detection` (baseline suite
29
+ runs twice; flaky files go to `henitai_flaky_tests.json`, removed by
30
+ `henitai clean`). Without it nothing is retried, so runtime on survivor-heavy
31
+ suites drops. The ">5% of mutants needed a retry" warning is replaced by a
32
+ report of flaky tests and re-checked kills. Child reports now carry
33
+ `failed_tests`. Failures outside an example (an `after(:context)` hook error,
34
+ a suite-level hook, a Minitest result without a file) are attributed to their
35
+ spec file when RSpec names it; otherwise the run is flagged
36
+ `unattributed_failures`, which makes a kill genuine (never retried) and makes
37
+ the flaky baseline reject the run. Baseline runs that fail on a file the two
38
+ runs cannot call flaky (for example one seen in a single run) now abort
39
+ instead of being accepted. Minitest outcomes are keyed to the file defining
40
+ the test class, not to a shared module that supplies the test method.
41
+ - Mutant runs are classified from an explicit report written by the child
42
+ process instead of from its exit status. An exception while activating the
43
+ mutant (for example a `NameError` from a superclass defined in another file)
44
+ is `CompileError`, not `Killed`. A child killed by a signal, or one that
45
+ exits without reporting, is `RuntimeError`, not `Killed`. A `SystemExit`
46
+ raised by the code under test is `RuntimeError`, not `Survived`.
47
+ - Mutants are activated only after the spec or test files have loaded, so a
48
+ subject whose superclass or constants live in other files can be tested.
49
+ Previously the child loaded the subject file on its own and failed with a
50
+ `NameError` (every `ApplicationRecord` model, for example).
51
+ - Mutants are activated by re-evaluating the subject's own `def` inside its
52
+ original module and class openers (ADR-13), not through `define_method`.
53
+ Constant lookup through enclosing modules, implicit `super`, `yield`,
54
+ `block_given?`, argument forwarding, default values, `__FILE__`/`__LINE__`,
55
+ `frozen_string_literal` and visibility now behave as in the original method.
56
+ Mutations in default values take effect, and splices no longer shift after
57
+ multibyte characters or leave heredoc bodies behind. Activation recipes from
58
+ earlier versions are ignored and regenerated.
59
+
60
+ ### Added
61
+
62
+ - `mutation.sampling.seed` (integer >= 0, default `0`) and real stratified
63
+ sampling: per subject `max(1, ceil(size * ratio))` mutants, strata by
64
+ operator ordered by `SHA256(seed, stable_id)`, visited round-robin, original
65
+ order kept. The report records `"sampling": {strategy, ratio, seed}`.
66
+ - Report fields: top-level `completeFiles` (non-authoritative runs) and
67
+ per-file `sessionId` and `gitSha` provenance.
68
+ - `rake oracle:reuse` (run in CI): on a scratch copy of the oracle corpus, the
69
+ `--incremental` verdicts must equal a fresh full run after scripted edits to
70
+ source, tests, a helper and the configuration.
71
+ - **Fork server for Minitest and parallel runs.** `mutation.fork_server`
72
+ (opt-in, ADR-15) now supports Minitest and `jobs > 1`: the environment is
73
+ booted once and each mutant process is forked from the server. Falls back to
74
+ per-mutant boot with a warning if the server is unavailable.
75
+ - A fidelity check rejects, before execution, every mutant whose activated
76
+ code differs from the original method by more than the reported mutation.
77
+ Examples are a negated condition that lost its parentheses, keyword
78
+ arguments re-rendered as a positional hash, `|x|` re-rendered as `|x,|`, a
79
+ splice shifted by a multibyte character, and a removed assignment that turns
80
+ a later local-variable read into a method call. Such mutants are reported as
81
+ `CompileError` with the reason.
82
+ - `mutation.control_runs` (default `true`): after execution, every subject
83
+ with a detected verdict runs once more with its unmutated method injected.
84
+ If its tests fail, the subject's detected verdicts are reclassified as
85
+ `CompileError`, because the injection itself broke them.
86
+ - Methods defined in a constructor block assigned to a constant
87
+ (`Point = Struct.new(:x) do ... end`, likewise `Data.define` and
88
+ `Class.new`) and in `class << CONST` are now subjects. Top-level
89
+ `def Const.name` methods are subjects as well.
90
+ - `rake oracle` runs a corpus whose verdicts follow from its construction and
91
+ fails on false kills or false survivors. CI runs it.
92
+
93
+ ### Fixed
94
+
95
+ - **Verdict reuse is bound to a reuse context.** `--incremental` reused stale
96
+ `Killed` verdicts after a spec helper, the configuration or the henitai
97
+ version changed. Every cached `Killed` and `Survived` verdict now stores a
98
+ `ReuseContext` fingerprint (test dependency files, henitai version,
99
+ integration, operators, `test_excludes`, `coverage_criteria`) and is reused
100
+ only under an identical one; rows recorded without it are never reused.
101
+ `rake oracle:reuse` reproduced 121 mismatches against fresh runs after a
102
+ helper edit before the fix and none after it.
103
+ - **`--survivors-from` no longer skips survivors on a git heuristic.** The
104
+ selected survivors go through the same content-fingerprint reuse rules as
105
+ `--incremental`, always: new or edited tests and helpers invalidate them,
106
+ reused survivors are reported with `fromCache`, and recipe stubs without a
107
+ source range are always re-executed. `SurvivorTestFilter` is removed.
108
+ The recipe fast path now refreshes coverage (Gate 0) so new or edited tests
109
+ are selected, recipe stubs also run their name-matched tests, and survivor
110
+ reruns never sample (sampling had dropped prior survivors before matching).
111
+ `--force` now also bypasses this reuse. Duplicate programs are collapsed
112
+ before sampling, so a sampled run keeps the same representative as an
113
+ unsampled survivor rerun and its survivors are found again.
114
+ - A survived verdict whose covering-test fingerprint cannot be recomputed keeps
115
+ stored fingerprints only from a survived row of the same subject source,
116
+ never from a killed row or an edited method.
117
+ - Gate 1 source selection has its own per-test coverage instance, so reuse and
118
+ history read the post-bootstrap per-test map.
119
+ - Recipe stubs keep their precomputed stable ID and persist the `replacement`
120
+ source in the history store.
121
+ - **Trend history only records authoritative runs.** `--since`,
122
+ subject-scoped and survivor-rerun results no longer add a `runs` row.
123
+ - **Canonical report merge.** A scoped run that mutated every subject of a file
124
+ replaces that file's prior entry wholesale (and removes it when it has no
125
+ mutants), so stale mutants of edited or deleted code no longer linger.
126
+ `--survivors-from <canonical report>` redirects to the session snapshot only
127
+ when every file entry carries the report's `sessionId`; a file whose every
128
+ prior mutant was re-run takes the new run's provenance.
129
+ - `--since` diffs against the merge base (`REF...HEAD`) instead of the ref's
130
+ tip, so commits that landed on the base branch after the fork point are not
131
+ treated as changes.
132
+ - The dashboard reporter uploads the merged canonical report for scoped runs
133
+ instead of the partial one.
134
+ - `Pending` mutants are excluded from the MS and MSI denominators; MSI is
135
+ `killed / (total - pending)`.
136
+ - **Equivalence detection only flags sound cases.** `false || x` and
137
+ `true && x` (left literal only); `n + 0`, `n * 1` and siblings only for
138
+ Integer/Float literal receivers (not `-0.0`); `==` <-> `eql?` only when both
139
+ operands are string literals; `==` <-> `equal?` for singleton literals.
140
+ Unsound rules (`x || false`, `x && true`, unknown receivers, a single string
141
+ literal) no longer mark mutants `Equivalent`.
142
+ - **`mutation.ignore_patterns` is line-scoped.** A pattern is matched against
143
+ the source line where the mutant's node starts, in `StaticFilter`; matching
144
+ mutants are reported as `Ignored` with an `ignore_reason` instead of being
145
+ dropped at generation time. An enclosing construct whose body matches is no
146
+ longer suppressed. The line is decoded honouring a magic encoding comment,
147
+ so a non-UTF-8 source file no longer aborts filtering.
148
+ - **Review follow-ups.** The slot scheduler no longer signals the process
149
+ group of a reaped pid that owns no mutant slot. Minitest results for tests
150
+ added by reopening a class in another file are attributed to the file that
151
+ defines the method, so coverage-first selection no longer misses them. With
152
+ the fork server active, calibrated timeouts keep the measured suite boot
153
+ (served mutants still run `before(:suite)` hooks). The `fork_server` schema
154
+ description and the remediation plan now state the real scope (RSpec and
155
+ Minitest, any `jobs`).
156
+ - **`--incremental` no longer reuses stale survived verdicts for def-line
157
+ mutants.** Their test selection is widened with name-matched tests that the
158
+ survivor fingerprint does not cover, so survived verdicts for mutants on a
159
+ method's def line always re-execute (killed reuse is unaffected: a kill by
160
+ an unchanged covering test remains valid).
161
+ - **Output capture no longer delays or races on lingering grandchildren.**
162
+ Closing a mutant child's captured stdout/stderr no longer waits up to 1 s
163
+ per stream for EOF and no longer kills the drain thread mid-write (which
164
+ could exceed the cap or write the truncation marker twice). The drain
165
+ thread is stopped cooperatively through a stop pipe and takes what is
166
+ already readable.
167
+ - **Fork-server lifecycle.** A server lost or stalled mid-run no longer turns
168
+ its in-flight mutants into `runtime_error`: they are rerun through the
169
+ per-mutant-boot fallback. Fallback runs use boot-inclusive timeouts. Mutant
170
+ processes forked by the server are cleaned up on timeout, loss and teardown
171
+ even before they lead a process group, and the connection and wake-pipe fds
172
+ are no longer inherited by mutant processes. The parallel event loop no
173
+ longer stalls on an exit event already buffered in the client, and the
174
+ SIGTERM drain window always runs its full length.
175
+ - The "negated condition" mutant now negates the whole condition
176
+ (`!(a > b)`), not just its left operand (`!a > b`).
177
+ - Source files are read as UTF-8 regardless of the process locale. With `LANG`
178
+ unset or `C`, any non-ASCII source file used to abort subject resolution.
179
+ - Per-test coverage now narrows RSpec test selection. RSpec records test files
180
+ as `./spec/x_spec.rb` while candidates arrive as `spec/x_spec.rb`, so the
181
+ lookup never matched and every mutant ran all candidate files.
182
+ - `--since` and the dirty-source and survivor checks now work for a project in
183
+ a sub-directory of its git repository and for non-ASCII file names.
184
+ - A corrupt or locked `mutation-history.sqlite3` no longer discards the run's
185
+ reports. History recording and export now warn instead.
186
+ - Forked mutant children reset the INT, TERM, HUP and CHLD handlers inherited
187
+ from the parent, so a draining SIGTERM is no longer ignored.
188
+ - A zero-example check no longer matches `10 examples, 0 failures`, nor a
189
+ `No examples found.` or `0 examples, 0 failures` quoted in failure output;
190
+ only the run's last summary line counts. Such kills were reported as
191
+ `CompileError`.
192
+ - A run in which harness errors (`CompileError`) left no mutant to score now
193
+ exits `1` with a warning instead of passing its threshold with nothing
194
+ tested.
195
+ - Mutating an `elsif` condition no longer rewrites the whole `if` chain; only
196
+ the condition changes.
197
+ - String parts of interpolated strings, symbols, backticks and regexps, and
198
+ elements of `%w` arrays, are no longer mutated as standalone strings. Their
199
+ replacement text changed the literal's syntax rather than its value.
200
+ - Operator replacements are parenthesised when spliced, so `a && b && c`
201
+ mutated to `a || b` no longer reads as `a || (b && c)`.
202
+ - Two mutants at the same position with the same description no longer share
203
+ one stable identity and history entry.
204
+ - `ReturnValue` mutates only the method's final expression, not every
205
+ expression that happens to equal it.
206
+ - Subject expressions for predicate, bang, setter and operator methods, such
207
+ as `Foo#empty?`, `Foo#save!`, `Foo#name=` and `Foo#==`, select their method
208
+ instead of nothing.
209
+ - Mutants from different operators that produce the same program run once.
210
+ - The implicit value of a shorthand hash pair (`build(config:)`) is no longer
211
+ mutated. Its replacement rewrote the key to `build(nil:)`.
212
+ - A mutant that removes the only assignment of a local variable read later in
213
+ the method is no longer generated. Ruby parses the later read as a method
214
+ call, so every such mutant failed with a `NameError` wherever it ran.
215
+ - Subjects are attributed to the right namespace. A `class ::Top` inside
216
+ `module Outer` is `Top`, not `Outer::Top`. A `def Other.name` inside `Outer`
217
+ belongs to `Other`, not `Outer`. Methods of `class << obj` or `def obj.name`
218
+ for a non-constant `obj` are no longer reported as class methods of the
219
+ enclosing class.
220
+ - "replaced method call with nil" on a call with a block replaces the call
221
+ together with its block. Replacing only the call left `nil { ... }`, which
222
+ does not parse, so these sites were never tested. Blocks attached to
223
+ logger, output and debug calls stay arid.
224
+ - `RegexMutator` no longer drops the interpolations and later lines of a
225
+ dynamic or multi-line regexp, which it now leaves unmutated. It skips
226
+ escaped characters and the contents of character classes, so `[^0-9]` is
227
+ no longer reported as a removed `^` anchor and `[+-]` no longer as a
228
+ quantifier swap.
229
+
10
230
  ## [0.5.3] - 2026-08-26
11
231
 
12
232
  ### Fixed
@@ -588,7 +808,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
588
808
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
589
809
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
590
810
 
591
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.5.3...HEAD
811
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.6.0...HEAD
812
+ [0.6.0]: https://github.com/martinotten/henitai/compare/v0.5.3...v0.6.0
592
813
  [0.5.3]: https://github.com/martinotten/henitai/compare/v0.5.2...v0.5.3
593
814
  [0.5.2]: https://github.com/martinotten/henitai/compare/v0.5.1...v0.5.2
594
815
  [0.5.1]: https://github.com/martinotten/henitai/compare/v0.5.0...v0.5.1
data/README.md CHANGED
@@ -82,12 +82,17 @@ test_excludes:
82
82
  mutation:
83
83
  operators: light # light | full
84
84
  timeout: 10.0
85
+ timeout_multiplier: 3.0
85
86
  max_flaky_retries: 3
87
+ flaky_detection: false # run the baseline twice to find flaky test files
86
88
  max_log_bytes: 5000000
87
89
  max_timeout: 30.0
90
+ control_runs: true # re-run detected subjects with the unmutated method
91
+ fork_server: false # opt-in: boot the RSpec/Minitest environment once, fork each mutant run from it (any jobs; falls back to per-mutant boot with a warning)
88
92
  sampling:
89
93
  ratio: 0.05
90
94
  strategy: stratified
95
+ seed: 0 # integer >= 0; selects which mutants of each stratum are kept
91
96
  reports_dir: reports
92
97
  reporters:
93
98
  - terminal
@@ -118,25 +123,79 @@ baseline suite has a fixed timeout. Lines covered only by excluded tests count
118
123
  as uncovered and are not mutated. Excluding every test file aborts with
119
124
  `Henitai::ConfigurationError`.
120
125
  `mutation.max_log_bytes` is a positive per-stream byte cap for captured child
121
- output. `mutation.max_timeout` caps auto-calibrated timeouts; an explicit
126
+ output. Without an explicit `mutation.timeout`, each mutant's timeout is
127
+ calibrated as `mutation.timeout_multiplier` (default `3.0`) × (the summed
128
+ durations of the selected test files + the measured suite boot time), with a
129
+ 2 s floor. `mutation.max_timeout` (default `30.0`) caps the calibrated value,
130
+ but never below the measured baseline plus 2 s; when the baseline alone exceeds
131
+ `max_timeout`, Henitai warns once and uses baseline + 2 s. An explicit
122
132
  `mutation.timeout` still takes precedence. When JSON or HTML reporting is
123
133
  enabled, `reports.checkpoint*` controls periodic canonical-report checkpoints.
124
134
  Each checkpoint updates `mutation-report.json`; when the HTML reporter is
125
135
  configured, it also regenerates `mutation-report.html` from that merged JSON.
126
136
 
137
+ `mutation.sampling` keeps `ceil(size * ratio)` mutants per subject (at least
138
+ one). Mutants are stratified by operator: each operator's mutants are ordered
139
+ by a SHA256 of `seed` and the mutant's stable ID, and the strata are visited
140
+ round-robin, so every operator is represented before any contributes a second
141
+ mutant. The kept mutants retain their original order. The same seed selects the
142
+ same mutants across runs and line shifts; change `seed` to draw another sample.
143
+ The applied `{strategy, ratio, seed}` is recorded in the report under
144
+ `sampling`.
145
+
146
+ Scoped runs (`--since`, subject patterns, survivor reruns) are merged into the
147
+ existing `mutation-report.json` by `stableId`. A run that mutated every
148
+ subject of a file (a non-authoritative run without subject patterns, such as
149
+ `--since`) lists it under the top-level `completeFiles` and replaces that
150
+ file's prior entry wholesale, dropping it when it yields no mutants, so stale
151
+ mutants of edited or deleted code do not linger. Each file entry carries
152
+ `sessionId` and `gitSha` provenance of the run that produced it; a file merged
153
+ from several sessions has none. `--since` diffs against the merge base
154
+ (`REF...HEAD`), so commits that landed on the base branch after you forked do
155
+ not count as your changes.
156
+
127
157
  Before mutation testing starts, Henitai checks whether the current coverage data
128
158
  covers the configured source files. If not, Henitai runs the configured test
129
159
  suite once to bootstrap a usable coverage baseline. If coverage is still
130
160
  unavailable for the current sources, `henitai run` aborts with
131
161
  `Henitai::CoverageError`.
132
162
 
133
- Surviving mutants are retried up to `mutation.max_flaky_retries` times before
134
- they are classified as survivors. The default retry budget is 3.
163
+ Survivors are never retried: flakiness can only turn a survivor into a kill, so
164
+ a retry could only inflate the score. A killed mutant is retried, up to
165
+ `mutation.max_flaky_retries` times (default 3), only when every test file that
166
+ failed for it is a known flaky test; if any retry survives, the mutant is
167
+ reported as `Survived`.
168
+
169
+ Known flaky tests come from the opt-in `mutation.flaky_detection: true`. The
170
+ baseline suite then runs twice (existing coverage is not reused), and test files
171
+ whose pass/fail outcome differs between the runs are written to
172
+ `reports/henitai_flaky_tests.json`. Without `flaky_detection` there is no flaky
173
+ set and nothing is retried. At the end of the run Henitai reports the flaky
174
+ tests and how many kills were re-checked.
175
+
176
+ A mutant only counts as detected when a test failed because of the mutation.
177
+ Before a mutant runs, Henitai checks that its activated code differs from the
178
+ original method by exactly the reported mutation; a mutant that fails this
179
+ check is reported as `CompileError` with the reason in `statusReason` instead
180
+ of running. Each mutant child reports what it got to — a failed activation, a
181
+ finished test run, or a `SystemExit` raised by the code under test — and only
182
+ a finished test run yields `Killed` or `Survived`. A failed activation is a
183
+ `CompileError`; a child killed by a signal, one that exits without reporting,
184
+ and a `SystemExit` during the tests are `RuntimeError`. With
185
+ `mutation.control_runs` (on by default), every subject that has detected
186
+ mutants is run once more with its unmutated method injected; if its tests fail
187
+ then, the injection itself is broken for that subject and its detected
188
+ verdicts are reclassified as `CompileError`.
135
189
 
136
190
  Per-test coverage reporting is currently wired through the RSpec child runner.
137
191
  Minitest integration reuses the same selection and execution flow, but does not
138
192
  yet enable the per-test coverage formatter.
139
193
 
194
+ Test selection is coverage first: the per-test coverage map is the primary source
195
+ of candidate tests for a mutant, and name matching is only the fallback for when
196
+ the map cannot decide (no map, no location, or no covering test left in the
197
+ integration).
198
+
140
199
  Henitai currently defaults to linear mutant execution. Set `jobs` in
141
200
  `.henitai.yml` or pass `--jobs N` to opt into parallel mutant execution.
142
201
 
@@ -161,6 +220,13 @@ for framework errors. With `--strict-exit-codes` (opt-in, additive) it also
161
220
  exits `3` when one or more mutants timed out and `4` when runtime/compile
162
221
  errors are present; precedence `2` > `3` > `4` > `1` > `0`. The timeout code
163
222
  is informational — a run can pass its threshold and still exit `3`.
223
+ A run with no scoreable mutant passes its threshold only when there was
224
+ nothing to score (no changed code, everything ignored). When `CompileError`
225
+ harness errors emptied the score denominator, nothing was tested, and the run
226
+ exits `1`.
227
+ `Pending` mutants (left over from an interrupted run, without a verdict) are
228
+ excluded from both the MS and the MSI denominators; MSI is
229
+ `killed / (total - pending)`.
164
230
 
165
231
  For pull-request feedback, add the `github` reporter to `reporters:` in
166
232
  `.henitai.yml`: it prints one `::warning file=...,line=...` workflow command
@@ -174,10 +240,13 @@ and every covering test file are byte-identical to what was recorded. A
174
240
  `Survived` verdict needs more proof, because a *new* test could now kill it:
175
241
  it is reused only when, additionally, the live covering set derived from the
176
242
  current per-test coverage map matches the recorded set exactly (no test
177
- added, dropped, or changed) and the dependency fingerprint (spec helpers,
178
- `spec/support`, fixtures/factories, `Gemfile.lock`, `.henitai.yml`, `.rspec`)
179
- is unchanged. When per-test coverage is unavailable, survivors simply
180
- re-execute — reuse never happens on doubt (see ADR-11).
243
+ added, dropped, or changed). Both kinds additionally require the *reuse
244
+ context* to be unchanged: a fingerprint of the test dependency files (spec
245
+ helpers, `spec/support`, fixtures/factories, `Gemfile.lock`, `.henitai.yml`,
246
+ `.rspec`), the henitai version, the integration, the operator set,
247
+ `test_excludes` and `coverage_criteria`. Verdicts recorded without a reuse
248
+ context are never reused. When per-test coverage is unavailable, survivors
249
+ simply re-execute — reuse never happens on doubt (see ADR-11).
181
250
  Reused mutants stay visible in the report (`fromCache: true` beside
182
251
  `stableId`) and count toward MS/MSI; the terminal prints
183
252
  `N of M verdicts reused from history (K killed, S survived)` plus an
@@ -186,7 +255,8 @@ results.
186
255
  Generated mutants temporarily include `legacyStableId` so scoped runs replace
187
256
  canonical entries written before site-offset IDs were introduced instead of
188
257
  duplicating them.
189
- Timeouts and errors always re-execute. `--force` bypasses reuse.
258
+ Timeouts and errors always re-execute. `--force` bypasses reuse, including
259
+ the reuse applied to `--survivors-from` reruns.
190
260
  `henitai run --dry-run` lists the post-filter mutant set without executing
191
261
  mutants and always exits `0`. Gate 0 may still run the configured test suite to
192
262
  refresh baseline coverage.
@@ -201,9 +271,20 @@ authoritative, and the file may be the only artifact a dry run creates.
201
271
 
202
272
  `henitai run --survivors-from ...` performs a partial rerun: it reports only
203
273
  the selected survivors, skips threshold-based exit checks, and does not update
204
- the run trend history. Dirty worktree changes are included, so you can edit
274
+ the run trend history. The selected survivors go through the same reuse rules
275
+ as `--incremental`, always, not only with that flag: a survivor is skipped
276
+ (reported with `fromCache: true`) only when its subject source, its covering
277
+ test set and the reuse context are unchanged, so new or edited tests and
278
+ helpers invalidate it. Dirty worktree changes are included, so you can edit
205
279
  tests locally without committing first; if source files under `includes` are
206
- dirty, Henitai reruns the matched survivors conservatively.
280
+ dirty, the activation-recipe fast path is not used and the survivors are
281
+ re-matched against freshly generated mutants. Survivors restored from
282
+ activation recipes have no source range to fingerprint and are always
283
+ re-executed. When `--survivors-from` names a canonical report, Henitai uses the
284
+ matching session snapshot only if every file entry carries that report's
285
+ `sessionId`; a report merged from several runs is used as is.
286
+ Only authoritative runs (no `--since`, no subject patterns, no survivor rerun)
287
+ add a row to the trend history.
207
288
 
208
289
  ### Skipping mutations inline
209
290
 
@@ -226,8 +307,11 @@ standalone comment in the contiguous comment block directly above a `def`
226
307
  (a blank line breaks the association). Trailing text is allowed
227
308
  (`# henitai:disable -- reviewed defensive branch`). Skipped mutants are
228
309
  reported as `Ignored`, so they stay visible in the report instead of silently
229
- vanishing. Use `mutation.ignore_patterns` for repo-wide policy and
230
- `# henitai:disable` for one-off, reviewed exclusions.
310
+ vanishing. `mutation.ignore_patterns` is matched against the source line where
311
+ each mutant's node starts, so only mutants on a matching line are reported as
312
+ `Ignored`; an enclosing `if` or block whose body matches stays mutated. Use
313
+ `mutation.ignore_patterns` for repo-wide policy and `# henitai:disable` for
314
+ one-off, reviewed exclusions.
231
315
 
232
316
  Directives can also target specific operators, carry a reason (serialized as
233
317
  `statusReason`, visible in the HTML report), and cover regions:
@@ -307,7 +391,8 @@ dashboard:
307
391
  Set `STRYKER_DASHBOARD_API_KEY` in your CI environment to publish reports. When
308
392
  the key, project, and version are present, the `dashboard` reporter uploads the
309
393
  Stryker-schema report to the dashboard REST API (default
310
- `https://dashboard.stryker-mutator.io`); otherwise it is skipped silently. The
394
+ `https://dashboard.stryker-mutator.io`); otherwise it is skipped silently.
395
+ Scoped runs upload the merged canonical report rather than their partial one. The
311
396
  project defaults to the git remote and the version to the current branch or CI
312
397
  ref (`GITHUB_REF_NAME`/`GITHUB_REF`/`GITHUB_SHA`).
313
398
 
@@ -323,6 +408,8 @@ bundle install
323
408
  bundle exec rspec # run tests
324
409
  bundle exec ruby bin/verify-process-free-specs # verify self-mutation-safe specs
325
410
  bundle exec rake smoke:integration:all # run rspec/minitest integration smoke projects
411
+ bundle exec rake oracle # measure false kills/survivors on the oracle corpus
412
+ bundle exec rake oracle:reuse # check --incremental verdicts equal fresh runs
326
413
  bundle exec rubocop # lint
327
414
  bundle exec henitai clean # remove stale generated report artifacts
328
415
  bundle exec henitai run # dogfood
@@ -65,6 +65,21 @@
65
65
  "type": "number",
66
66
  "exclusiveMinimum": 0
67
67
  },
68
+ "flaky_detection": {
69
+ "type": "boolean",
70
+ "default": false,
71
+ "description": "Run the baseline suite twice and mark test files whose outcome differs between runs as flaky. Only killed mutants whose killing tests are all flaky are re-checked, up to max_flaky_retries times."
72
+ },
73
+ "fork_server": {
74
+ "type": "boolean",
75
+ "default": false,
76
+ "description": "Opt-in. Boot the test environment once in a long-lived fork server and fork each mutant run from it instead of re-booting per mutant. Supported for RSpec and Minitest with any jobs value; unsupported integrations and a server that fails to boot fall back to per-mutant boot with a warning."
77
+ },
78
+ "control_runs": {
79
+ "type": "boolean",
80
+ "default": true,
81
+ "description": "After execution, re-run each subject that has detected mutants with its unmutated method injected; if its tests fail, the subject's detections are reclassified as CompileError."
82
+ },
68
83
  "ignore_patterns": {
69
84
  "type": "array",
70
85
  "items": {
@@ -83,6 +98,11 @@
83
98
  },
84
99
  "strategy": {
85
100
  "enum": ["stratified"]
101
+ },
102
+ "seed": {
103
+ "type": "integer",
104
+ "minimum": 0,
105
+ "default": 0
86
106
  }
87
107
  }
88
108
  }
@@ -10,32 +10,17 @@ module Henitai
10
10
  INVARIANT_METHODS = %i[is_a? respond_to? kind_of?].freeze
11
11
  DSL_METHODS = %i[let subject before after].freeze
12
12
 
13
- def suppressed?(node, config)
14
- custom_pattern_match?(node, config) || catalog_match?(node)
13
+ def suppressed?(node)
14
+ catalog_match?(node)
15
15
  end
16
16
 
17
17
  private
18
18
 
19
- def custom_pattern_match?(node, config)
20
- source = node.location&.expression&.source
21
- return false unless source
22
-
23
- compiled_ignore_patterns(config).any? do |pattern|
24
- pattern.match?(source)
25
- end
26
- end
27
-
28
- def compiled_ignore_patterns(config)
29
- patterns = Array(config&.ignore_patterns).dup.freeze
30
- @compiled_ignore_patterns ||= {}
31
- @compiled_ignore_patterns[patterns] ||= patterns.map { |pattern| Regexp.new(pattern) }
32
- end
33
-
34
19
  def catalog_match?(node)
35
20
  case node.type
36
21
  when :send
37
22
  send_match?(node)
38
- when :block
23
+ when :block, :numblock
39
24
  block_match?(node)
40
25
  when :or_asgn
41
26
  # Memoization-style ||= is treated as arid on purpose. UpdateOperator
@@ -64,7 +49,7 @@ module Henitai
64
49
  return false unless send_node&.type == :send
65
50
 
66
51
  receiver, method_name, = send_node.children
67
- receiver.nil? && DSL_METHODS.include?(method_name.to_sym)
52
+ (receiver.nil? && DSL_METHODS.include?(method_name.to_sym)) || send_match?(send_node)
68
53
  end
69
54
 
70
55
  def direct_output_call?(receiver, method_name)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Explains a failed baseline suite run, pointing at its log when one exists.
5
+ module BootstrapErrorMessage
6
+ GENERIC = "Configured test suite failed while generating coverage"
7
+
8
+ module_function
9
+
10
+ def build(result)
11
+ return GENERIC unless result.respond_to?(:log_path)
12
+
13
+ tail = result.tail(12).strip
14
+ message = +GENERIC
15
+ message << " (see #{result.log_path})"
16
+ message << "\n#{tail}" unless tail.empty?
17
+ message
18
+ end
19
+ end
20
+ end
@@ -11,6 +11,11 @@ module Henitai
11
11
  # falls back to the current run's schema by itself -- structurally never
12
12
  # worse than the unconditional overwrite this replaces.
13
13
  #
14
+ # Files the current run lists under `completeFiles` (it mutated every subject
15
+ # of them) are the exception: their prior entry is replaced wholesale, or
16
+ # removed when the run produced no mutants for them, so stale mutants of
17
+ # changed or deleted code cannot linger next to the new ones.
18
+ #
14
19
  # Merges at mutant granularity, keyed by `stableId`, not file granularity:
15
20
  # a `--survivors-from` rerun's schema only contains the re-verified
16
21
  # survivors for a file, so replacing that file's whole entry would drop
@@ -37,10 +42,12 @@ module Henitai
37
42
  end
38
43
 
39
44
  def self.merge_files(current, prior, prune_missing:)
40
- merged_files = overlay_current_mutants(deep_dup(prior["files"]), current)
45
+ prior_files = deep_dup(prior["files"])
46
+ Array(current["completeFiles"]).each { |file| prior_files.delete(file) }
47
+ merged_files = overlay_current_mutants(prior_files, current)
41
48
  merged_files.reject! { |_, file| file["mutants"].empty? }
42
49
  prune_missing_source_files(merged_files) if prune_missing
43
- current.merge("files" => merged_files)
50
+ current.except("completeFiles").merge("files" => merged_files)
44
51
  end
45
52
  private_class_method :merge_files
46
53
 
@@ -68,11 +75,25 @@ module Henitai
68
75
  def self.append_current_mutants(merged_files, current)
69
76
  current.fetch("files", {}).each do |file, entry|
70
77
  target = merged_files[file] ||= { "mutants" => [] }
78
+ merge_provenance(target, entry)
71
79
  target["source"] = entry["source"]
72
80
  target["language"] = entry["language"]
73
81
  target["mutants"] += entry["mutants"]
74
82
  end
75
83
  end
84
+
85
+ # A file merged from several sessions has no single snapshot holding all
86
+ # its mutants, so its provenance is dropped unless both sides agree.
87
+ def self.merge_provenance(target, entry)
88
+ provenance = entry.slice("sessionId", "gitSha")
89
+ # A fresh run with no prior mutants left in this file: the current
90
+ # entry's provenance covers every mutant in it.
91
+ fresh = target["mutants"].empty?
92
+ agree = fresh || target["sessionId"] == entry["sessionId"]
93
+ %w[sessionId gitSha].each { |key| target.delete(key) }
94
+ target.merge!(provenance) if agree
95
+ end
96
+ private_class_method :merge_provenance
76
97
  private_class_method :append_current_mutants
77
98
 
78
99
  def self.mutant_ids(schema)