henitai 0.5.1 → 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 +296 -1
  3. data/README.md +109 -16
  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 +57 -15
  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 +17 -7
  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 +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
data/README.md CHANGED
@@ -77,17 +77,22 @@ excludes:
77
77
  - lib/henitai/eager_load.rb # standalone entry points with no in-process coverage
78
78
 
79
79
  test_excludes:
80
- - spec/end_to_end/*_spec.rb # never select these files for individual mutants
80
+ - spec/end_to_end/*_spec.rb # never run these files, mutants or coverage baseline
81
81
 
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
@@ -109,28 +114,88 @@ when a value is invalid.
109
114
 
110
115
  CLI flags override the corresponding values from `.henitai.yml`.
111
116
 
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.
117
+ `test_excludes` removes matching test files from per-mutant selection *and* from
118
+ the coverage-bootstrap suite. A mutant with no tests left is reported as
119
+ `NoCoverage` and is not executed. Excluding slow end-to-end or integration
120
+ suites therefore also keeps them out of the baseline run — which matters,
121
+ because a test that never runs against a mutant can never kill one, and the
122
+ baseline suite has a fixed timeout. Lines covered only by excluded tests count
123
+ as uncovered and are not mutated. Excluding every test file aborts with
124
+ `Henitai::ConfigurationError`.
114
125
  `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
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
116
132
  `mutation.timeout` still takes precedence. When JSON or HTML reporting is
117
133
  enabled, `reports.checkpoint*` controls periodic canonical-report checkpoints.
118
134
  Each checkpoint updates `mutation-report.json`; when the HTML reporter is
119
135
  configured, it also regenerates `mutation-report.html` from that merged JSON.
120
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
+
121
157
  Before mutation testing starts, Henitai checks whether the current coverage data
122
158
  covers the configured source files. If not, Henitai runs the configured test
123
159
  suite once to bootstrap a usable coverage baseline. If coverage is still
124
160
  unavailable for the current sources, `henitai run` aborts with
125
161
  `Henitai::CoverageError`.
126
162
 
127
- Surviving mutants are retried up to `mutation.max_flaky_retries` times before
128
- 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`.
129
189
 
130
190
  Per-test coverage reporting is currently wired through the RSpec child runner.
131
191
  Minitest integration reuses the same selection and execution flow, but does not
132
192
  yet enable the per-test coverage formatter.
133
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
+
134
199
  Henitai currently defaults to linear mutant execution. Set `jobs` in
135
200
  `.henitai.yml` or pass `--jobs N` to opt into parallel mutant execution.
136
201
 
@@ -155,6 +220,13 @@ for framework errors. With `--strict-exit-codes` (opt-in, additive) it also
155
220
  exits `3` when one or more mutants timed out and `4` when runtime/compile
156
221
  errors are present; precedence `2` > `3` > `4` > `1` > `0`. The timeout code
157
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)`.
158
230
 
159
231
  For pull-request feedback, add the `github` reporter to `reporters:` in
160
232
  `.henitai.yml`: it prints one `::warning file=...,line=...` workflow command
@@ -168,10 +240,13 @@ and every covering test file are byte-identical to what was recorded. A
168
240
  `Survived` verdict needs more proof, because a *new* test could now kill it:
169
241
  it is reused only when, additionally, the live covering set derived from the
170
242
  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).
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).
175
250
  Reused mutants stay visible in the report (`fromCache: true` beside
176
251
  `stableId`) and count toward MS/MSI; the terminal prints
177
252
  `N of M verdicts reused from history (K killed, S survived)` plus an
@@ -180,7 +255,8 @@ results.
180
255
  Generated mutants temporarily include `legacyStableId` so scoped runs replace
181
256
  canonical entries written before site-offset IDs were introduced instead of
182
257
  duplicating them.
183
- 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.
184
260
  `henitai run --dry-run` lists the post-filter mutant set without executing
185
261
  mutants and always exits `0`. Gate 0 may still run the configured test suite to
186
262
  refresh baseline coverage.
@@ -195,9 +271,20 @@ authoritative, and the file may be the only artifact a dry run creates.
195
271
 
196
272
  `henitai run --survivors-from ...` performs a partial rerun: it reports only
197
273
  the selected survivors, skips threshold-based exit checks, and does not update
198
- 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
199
279
  tests locally without committing first; if source files under `includes` are
200
- 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.
201
288
 
202
289
  ### Skipping mutations inline
203
290
 
@@ -220,8 +307,11 @@ standalone comment in the contiguous comment block directly above a `def`
220
307
  (a blank line breaks the association). Trailing text is allowed
221
308
  (`# henitai:disable -- reviewed defensive branch`). Skipped mutants are
222
309
  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.
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.
225
315
 
226
316
  Directives can also target specific operators, carry a reason (serialized as
227
317
  `statusReason`, visible in the HTML report), and cover regions:
@@ -301,7 +391,8 @@ dashboard:
301
391
  Set `STRYKER_DASHBOARD_API_KEY` in your CI environment to publish reports. When
302
392
  the key, project, and version are present, the `dashboard` reporter uploads the
303
393
  Stryker-schema report to the dashboard REST API (default
304
- `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
305
396
  project defaults to the git remote and the version to the current branch or CI
306
397
  ref (`GITHUB_REF_NAME`/`GITHUB_REF`/`GITHUB_SHA`).
307
398
 
@@ -317,6 +408,8 @@ bundle install
317
408
  bundle exec rspec # run tests
318
409
  bundle exec ruby bin/verify-process-free-specs # verify self-mutation-safe specs
319
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
320
413
  bundle exec rubocop # lint
321
414
  bundle exec henitai clean # remove stale generated report artifacts
322
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)
@@ -43,7 +43,8 @@ module Henitai
43
43
  survivors_from: resolved_survivors_from,
44
44
  mode: {
45
45
  dry_run: options.fetch(:dry_run, false),
46
- incremental: options.fetch(:incremental, false) && !options.fetch(:force, false)
46
+ incremental: options.fetch(:incremental, false) && !options.fetch(:force, false),
47
+ force: options.fetch(:force, false)
47
48
  }
48
49
  )
49
50
  runner.run
@@ -64,7 +65,7 @@ module Henitai
64
65
  # being a snapshot path; this keeps activation-recipes lookup correct.
65
66
  return survivors_from if File.basename(parent_dir) == "sessions"
66
67
 
67
- session_id = session_id_from_report(survivors_from)
68
+ session_id = single_session_id_from_report(survivors_from)
68
69
  return survivors_from if session_id.nil?
69
70
 
70
71
  snapshot_path = survivors_snapshot_path(report_dir, session_id)
@@ -85,9 +86,16 @@ module Henitai
85
86
  File.join(report_dir, "sessions", session_id, "mutation-report.json")
86
87
  end
87
88
 
88
- def session_id_from_report(path)
89
+ # The report's sessionId only names a snapshot holding every survivor when
90
+ # each file entry was produced by that very session. A merged canonical
91
+ # report (or a legacy one without per-file provenance) is used as is.
92
+ def single_session_id_from_report(path)
89
93
  parsed = JSON.parse(File.read(path))
90
- parsed["sessionId"]
94
+ session_id = parsed["sessionId"]
95
+ files = parsed["files"]
96
+ return nil unless files.is_a?(Hash) && files.each_value.all? { |f| f["sessionId"] == session_id }
97
+
98
+ session_id
91
99
  rescue JSON::ParserError, Errno::ENOENT
92
100
  nil
93
101
  end
@@ -104,6 +112,18 @@ module Henitai
104
112
  strict_status || threshold_status_for(result, config)
105
113
  end
106
114
 
115
+ # An empty score denominator passes only when there was nothing to score
116
+ # (no changed code, everything ignored). When harness errors emptied it,
117
+ # nothing was tested, and the run must not pass its threshold.
118
+ def empty_score_status(result)
119
+ harness_errors = result.mutants.count { |mutant| mutant.status == :compile_error }
120
+ return 0 if harness_errors.zero?
121
+
122
+ warn "henitai: no mutant could be scored: #{harness_errors} harness error(s) " \
123
+ "(CompileError, see statusReason in the report)"
124
+ 1
125
+ end
126
+
107
127
  # Expanded, opt-in exit codes (precedence: timeout > runtime/compile
108
128
  # error > threshold miss). The timeout code is informational and
109
129
  # independent of coverage_criteria.timeout: a run can pass its threshold
@@ -118,9 +138,7 @@ module Henitai
118
138
 
119
139
  def threshold_status_for(result, config)
120
140
  score = result.mutation_score
121
- # No valid mutants to evaluate (e.g. an incremental run with no changed
122
- # code) cannot fail a threshold — treat it as success.
123
- return 0 if score.nil?
141
+ return empty_score_status(result) if score.nil?
124
142
 
125
143
  score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
126
144
  end
@@ -79,7 +79,7 @@ module Henitai
79
79
  def add_force_option(opts, options)
80
80
  opts.on(
81
81
  "--force",
82
- "Bypass verdict reuse and execute every mutant (only meaningful with --incremental)"
82
+ "Bypass verdict reuse (--incremental, --survivors-from) and execute every mutant"
83
83
  ) do
84
84
  options[:force] = true
85
85
  end
data/lib/henitai/cli.rb CHANGED
@@ -37,7 +37,10 @@ module Henitai
37
37
  %w[coverage .resultset.json],
38
38
  %w[coverage .last_run.json],
39
39
  ["henitai_per_test.json"],
40
- [CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE]
40
+ [PerTestCoverageCollector::BOOT_FILE_NAME],
41
+ [CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE],
42
+ [FlakyTestRegistry::FILE_NAME],
43
+ [TestFileOutcomes::REPORT_FILE_NAME]
41
44
  ].freeze
42
45
 
43
46
  # Whole directories of per-mutant scratch artifacts, removed recursively.
@@ -43,7 +43,7 @@ module Henitai
43
43
 
44
44
  attr_reader :integration, :includes, :excludes, :test_excludes, :operators, :timeout,
45
45
  :timeout_multiplier, :ignore_patterns, :sampling, :jobs,
46
- :max_flaky_retries, :max_log_bytes, :max_timeout,
46
+ :max_flaky_retries, :max_log_bytes, :max_timeout, :control_runs, :flaky_detection, :fork_server,
47
47
  :coverage_criteria, :thresholds,
48
48
  :reporters, :reports_dir,
49
49
  :checkpoint_enabled, :checkpoint_every, :checkpoint_interval,
@@ -124,6 +124,9 @@ module Henitai
124
124
  @max_flaky_retries = mutation.fetch(:max_flaky_retries, DEFAULT_MAX_FLAKY_RETRIES)
125
125
  @max_log_bytes = mutation[:max_log_bytes] || DEFAULT_MAX_LOG_BYTES
126
126
  @max_timeout = mutation[:max_timeout] || DEFAULT_MAX_TIMEOUT
127
+ @control_runs = mutation.fetch(:control_runs, true)
128
+ @flaky_detection = mutation.fetch(:flaky_detection, false)
129
+ @fork_server = mutation.fetch(:fork_server, false)
127
130
  end
128
131
 
129
132
  def apply_reports_defaults(raw)
@@ -100,6 +100,13 @@ module Henitai
100
100
  Scalars.validate_max_flaky_retries(value[:max_flaky_retries])
101
101
  Scalars.validate_max_log_bytes(value[:max_log_bytes])
102
102
  Scalars.validate_max_timeout(value[:max_timeout])
103
+ validate_mutation_flags(value)
104
+ end
105
+
106
+ def validate_mutation_flags(value)
107
+ %i[control_runs flaky_detection fork_server].each do |key|
108
+ Scalars.validate_boolean(value[key], "mutation.#{key}") unless value[key].nil?
109
+ end
103
110
  end
104
111
 
105
112
  def validate_mutation_filters(value)
@@ -133,6 +140,7 @@ module Henitai
133
140
  Scalars.validate_sampling_completeness(value)
134
141
  Scalars.validate_sampling_ratio(value[:ratio])
135
142
  Scalars.validate_sampling_strategy(value[:strategy])
143
+ Scalars.validate_sampling_seed(value[:seed])
136
144
  end
137
145
 
138
146
  def warn_unknown_keys(raw, allowed_keys, path = nil)
@@ -150,6 +150,15 @@ module Henitai
150
150
  )
151
151
  end
152
152
 
153
+ def validate_sampling_seed(value)
154
+ return if value.nil?
155
+ return if value.is_a?(Integer) && value >= 0
156
+
157
+ Rules.configuration_error(
158
+ "Invalid configuration value for mutation.sampling.seed: expected Integer >= 0, got #{value.inspect}"
159
+ )
160
+ end
161
+
153
162
  def validate_sampling_completeness(value)
154
163
  return if value.key?(:ratio) && value.key?(:strategy)
155
164
 
@@ -26,9 +26,10 @@ module Henitai
26
26
  ].freeze
27
27
  VALID_MUTATION_KEYS = %i[
28
28
  operators timeout timeout_multiplier ignore_patterns max_flaky_retries max_log_bytes max_timeout sampling
29
+ control_runs flaky_detection fork_server
29
30
  ].freeze
30
31
  VALID_REPORTS_KEYS = %i[checkpoint checkpoint_every checkpoint_interval].freeze
31
- VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
32
+ VALID_SAMPLING_KEYS = %i[ratio strategy seed].freeze
32
33
  VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
33
34
  VALID_THRESHOLDS_KEYS = %i[high low].freeze
34
35
  VALID_DASHBOARD_KEYS = %i[project base_url].freeze
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Names constants as the source writes them and attributes them to the
5
+ # namespace they belong to, as far as the source alone can tell.
6
+ module ConstantPath
7
+ module_function
8
+
9
+ # The constant path of a +const+ or +casgn+ node as written;
10
+ # root-qualified paths keep their leading +::+. nil for other nodes.
11
+ def of(node)
12
+ return unless node.respond_to?(:type)
13
+
14
+ case node.type
15
+ when :const, :casgn
16
+ parent = of(node.children[0])
17
+ own = node.children[1].to_s
18
+ parent.nil? ? own : "#{parent}::#{own}"
19
+ when :cbase
20
+ ""
21
+ end
22
+ end
23
+
24
+ # +name+ inside +namespace+. A root-qualified name (+::Top+) ignores the
25
+ # enclosing namespace.
26
+ def qualify(namespace, name)
27
+ return name.delete_prefix("::") if name&.start_with?("::")
28
+ return name if namespace.nil? || namespace.empty?
29
+ return namespace if name.nil? || name.empty?
30
+
31
+ "#{namespace}::#{name}"
32
+ end
33
+
34
+ # The object a singleton method or singleton class is defined on: the
35
+ # enclosing namespace for +self+, the named constant otherwise, nil for
36
+ # any other object. A relative constant is attributed to the innermost
37
+ # lexical candidate, which Ruby also tries first. The source alone cannot
38
+ # tell whether +Other+ inside +module Outer+ is +Outer::Other+ or
39
+ # +::Other+, so this name can be off; activation resolves the real object
40
+ # at runtime (Mutant::Activator).
41
+ def owner(receiver, namespace)
42
+ case receiver.type
43
+ when :self then namespace
44
+ when :const then qualify(namespace, of(receiver))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Verifies, after execution, that the activation path itself does not break
5
+ # a subject's tests.
6
+ #
7
+ # For every subject with a detected verdict (Killed, Timeout, RuntimeError)
8
+ # the unmutated method is injected through the same activation and fork
9
+ # path, against the same test files. The tests must pass. If they do not,
10
+ # injecting the method is what made them fail -- a lost constant scope,
11
+ # `super` or `yield` inside `define_method`, a rebuilt parameter list -- so
12
+ # none of that subject's detections can be credited to its mutations. They
13
+ # are reclassified as CompileError with the control's outcome as reason.
14
+ #
15
+ # Verdicts reused from history are left alone: they were not produced by
16
+ # this run's activation.
17
+ class ControlRun
18
+ DETECTED = %i[killed timeout runtime_error].freeze
19
+
20
+ # @param timeout_for [#call] test files -> timeout in seconds
21
+ def initialize(integration:, timeout_for:)
22
+ @integration = integration
23
+ @timeout_for = timeout_for
24
+ end
25
+
26
+ # @return [Hash] verified:, failed: subjects; reclassified: mutants
27
+ def verify(mutants)
28
+ stats = { verified: 0, failed: 0, reclassified: 0 }
29
+ detected_groups(mutants).each_value do |detected|
30
+ stats[:verified] += 1
31
+ result = run_control(detected.first)
32
+ next if result.status == :survived
33
+
34
+ stats[:failed] += 1
35
+ stats[:reclassified] += reclassify(detected, result)
36
+ end
37
+ stats
38
+ end
39
+
40
+ private
41
+
42
+ def detected_groups(mutants)
43
+ Array(mutants)
44
+ .select { |mutant| DETECTED.include?(mutant.status) && !mutant.from_cache? && mutant.original_node }
45
+ .group_by { |mutant| [mutant.subject, test_files(mutant)] }
46
+ end
47
+
48
+ def run_control(mutant)
49
+ files = test_files(mutant)
50
+ @integration.run_mutant(mutant: control_for(mutant), test_files: files, timeout: @timeout_for.call(files))
51
+ end
52
+
53
+ def control_for(mutant)
54
+ node = mutant.original_node
55
+ Mutant.new(subject: mutant.subject, operator: "Control", nodes: { original: node, mutated: node },
56
+ description: "unmutated control", location: mutant.location)
57
+ end
58
+
59
+ def reclassify(detected, result)
60
+ reason = "control run failed: the unmutated method fails its tests when injected " \
61
+ "(#{[result.status, result.status_reason].compact.join(': ')})"
62
+ detected.each do |mutant|
63
+ mutant.status = :compile_error
64
+ mutant.status_reason = reason
65
+ end
66
+ detected.size
67
+ end
68
+
69
+ def test_files(mutant)
70
+ Array(mutant.covered_by).sort
71
+ end
72
+ end
73
+ end