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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- 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
|
|
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
|
|
113
|
-
with no tests left is reported as
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
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)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
224
|
-
|
|
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.
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|