Changelog
All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixed
- A bare
public,privateorprotectedendsmodule_functionmode, so laterdefs in the module body are named as instance methods, not singleton methods (#144). - Turning on reduced motion mid-visit finishes the landing page count-ups, so no number keeps changing after the visitor asks for less motion (#176).
rake site:buildcan no longer delete a directory such aslib/or.git(#162). The task removed the destination it was given, and only refused the checkout, its parents anddocs/. It now always builds into_site/at the checkout root, andrake "site:build[DEST]"andSITE_BUILD_DESTare gone.- Documentation matches the 1.4 behavior. Config examples use supported keys,
agent loops check the exit code and a real score, and report totals explain
partial
--fail-fastruns. The README lists every config key and its validation rules. API README links work outside GitHub, repeated changelog headings have unique anchors, and the website checks validate built links and anchors. Mutant-id wording and the gem's Minitest/RSpec description are also corrected.
1.4.0 - 2026-09-30
Added
- Condition-forcing operators (Tier-2, opt-in via
--operators):condition_trueandcondition_falsereplace anif/elsif/unless, ternary, modifier orcase/inguard condition with(true)or(false), so its branch always runs or never runs. A surviving mutant means no selected test detected the forced condition. A literal condition, also in parentheses, makes no mutant: forcing it changes nothing or repeats theboolean_literalflip. A condition that holds a heredoc makes no mutant. A condition that assigns a variable keeps its code, and only its value is forced ((m = x; true)), so later code still sees the variable. The never-runs side of an else-less conditional can be the same program as thenilthatstatement_removalorreturn_nilputs in its place; it is still made, so these mutants and their ids do not depend on which other operators run. - Operator-assignment operator (Tier-2, opt-in via
--operators):operator_assignmentswaps the operator of a compound assignment:+=<->-=,*=<->/=, and%=and**=->*=. It covers local, instance, class and global variables, constants, calls (a.b += 1) and index calls (a[i] += 1). Thearithmeticoperator never sees these forms, because Prism does not parse them as calls. The operator does not change||=,&&=, or the bitwise and shift forms (|=,<<=).
Fixed
- A typed
--rails,--verboseor--debugbeatsrails: falseandverbose: falsein.mutineer.yml(#103). The flag was dropped, so the run went on without Rails boot or verbose output. The config layers now keep only the keys the user wrote, and "did the user write this" comes from those keys, not from a hand-kept list. framework: rspecin.mutineer.ymlsurvives test pairing (#103). Pairing re-detected the framework from the test file names, so an RSpec suite intest/calc_test.rbran under Minitest.- A bad number is an error, not a rounded value (#105).
--jobs 1.9and--baseline-epsilon abcno longer become1and0.0. One option schema parses each value once, where it enters, for both the command line and.mutineer.yml. --sincekeeps a source file that git does not track yet (#156). Before, such a file counted as unchanged. The run scored no mutants, and a positive--thresholdstill exited 0. The file is now new in full, so all its mutants run.
Changed
- These now exit 2 with a message that names the option:
jobs: 1.9orjobs: truein.mutineer.yml(before:1, or a crash); a bad, negative or non-finite--baseline-epsilon(before:0.0); a boolean key in.mutineer.ymlthat is nottrueorfalse, such as the string"yes"(before:false). --jobstakes plain digits only.0x2,+2," 2"and1_0exit 2. Before,Integer()read them as 2, 2, 2 and 10.--thresholdand--baseline-epsilontake a plain decimal, such as2or2.5, from a string.0x10,+2,1_0and1e2exit 2. Before,Float()read them as 16.0, 2.0, 10.0 and 100.0.- A string option that gets
true,falseor no value exits 2, such asonly: falseorbaseline:in.mutineer.yml. A blanksincealso exits 2, in the file and on the command line, so an unset shell variable does not turn scoping off.since: falsestill means no scoping. - Config file errors start with the file and key, for example
.mutineer.yml: threshold must be a number between 0 and 100, where they started with--threshold. - The docs site is built in CI — a Pages workflow runs
rake site:buildand deploys the result, so the YARD HTML under/api/,llms-full.txt,json-schema.htmlandsitemap.xmlare no longer committed. CI checks that the site builds, in place ofrake yard:pages:check. The site keeps its URLs (#153).
1.3.0 - 2026-09-29
Changed
-
Mutant ids now include the project-relative file path (#126). Before, two
mutants with the same method name, operator and token in different files got
the same id. That happened with a top-level
defor block in two files, and with a class reopened in another file. Oneignore:entry then suppressed both mutants, and--baselinecould hide a new survivor behind an old one.- Every survivor id changes once in this release. This affects any external tool that tracks survivors by id.
- Moving or renaming a file now changes its ids.
- Ids follow the project root: the directory mutineer runs from, or the
Action's
working-directory. A run from a different root gives different ids. mutineer finds.mutineer.ymlby walking up, so a run from a subdirectory prints one[mutineer]warning that the loaded ignore ids will not match. - A source outside the project root uses its absolute path, so its ids differ between machines.
- Two methods with the same qualified name in one file (for example two
top-level
def indexin two DSL blocks) now get different ids. The second and later ones hash their position among those methods; the first keeps its id.
- The JSON report marks its id format (schema
1.4, additive).summary.id_formatis2for ids that include the file path.summary.legacy_id_matchescounts old-formatignore:entries (ignore) and survivors matched only through an old-format baseline id (baseline). The GitHub Action shows one warning annotation when either count is not zero.
Deprecated
- Old-format ids in
ignore:and in baselines. Matching on them is removed in 2.0. Replace each oldignore:entry with the new ids from the warning (only the intended ones when it over-matched several mutants). Regenerate a baseline (--format json) only after every gate that reads it runs this version or later (the Action'sversion:pin, your CIGemfile.lock). An older version treats every new-format survivor as new.
Fixed
module_function :nameandmodule_function def namepromote only their own module's methods — a class or module in the same file with a method of the same name kept an instance method in Ruby, but mutineer named it as a class method.--strategy redefinethen mutated a method the tests never call, so a killable mutant falsely survived, and--only Class#nameselected nothing (#98). The affected methods now get their correct names, so their mutant ids change: regenerate any ignore entries or baseline survivors that pointed at them.- A root-anchored reopening names the top-level constant — a
module ::Rootorclass ::Solowritten inside another module now givesRootandSolo, notOuter::RootandOuter::Solo. This applies to every method in such a body, with or withoutmodule_function, so their mutant ids change too.--strategy redefinerebuilds such a body's scope as written (module Outerthenclass ::Solo), so a constant fromOuterstill resolves in the mutated method, as it does underreload. Before, the method raised NameError in the test, which counted as a false kill (#145). - Old-format ids keep working, with a warning (#126). An old-format
ignore:entry still suppresses the mutants it matched before. The run prints one[mutineer]warning per entry with each new id, its file and its method. When the entry matched several mutants (in different files, or same-named methods in one file), the warning says it over-matched and to keep only the ids for the mutant you meant to ignore. A--baselinefile withoutsummary.id_formatmatches on new ids, or on old ids from the same file, so no survivor reads as new or fixed only because its id changed. A stored file outside the project root (a baseline written on another machine) matches on the old id alone. The run prints one[mutineer]warning to regenerate the baseline.
1.2.0 - 2026-09-28
Added
- Operand-removal operator (Tier-2, opt-in via
--operators):operand_removalreplacesa && bwith(a)and with(b), and does the same for||,andandor. The mutant survives when no test needs the operand that the mutant removes. The operator never keeps a jump operand (return,break,next,redo,retry) alone, because a jump does not parse in a value context. It never removes an operand that holds a heredoc, because the heredoc body stays behind as code. It skips nested method definitions, because mutineer mutates each one as its own method. - Array-literal operator (Tier-2, opt-in via
--operators):array_literalreplaces a non-empty array literal, such as[a, b]or%i[a b], with[]. The mutant survives when no test checks the contents of the array. The operator skips an implicit array (x = 1, 2), an array that holds a heredoc, and nested method definitions. - Sources also pair with Minitest's
test/**/test_*.rbfiles — after the_test.rbforms, so existing projects pair as before.lib/helper.rbdoes not pair with thetest/test_helper.rbsupport file. A failed capture of atest_<name>.rbfile now marks<name>.rbuncapturable, as<name>_test.rbdoes (#120). Withoutframework:set, a source withspec/<name>_spec.rbandtest/test_<name>.rbbut no<name>_test.rbnow pairs with the Minitest file, as the "Minitest first" order says.
Fixed
reloadloads the mutant by an absolute path — a relative source path gave the mutant relative backtrace paths, so code that checks its own frames by absolute path failed for every mutant, a false kill (#123).require "test_helper"works withoutRUBYOPT— a standalone run putslib, then each test file'stest_helper.rbdirectory, on the load path, as boot mode andrake testdo. A run where no test records coverage because captures failed now exits 1 instead of reporting N/A (#119).- A disable-line marker warns about an operator it does not know — a
reason written without
--became part of the operator name, so the marker suppressed nothing and said nothing (#124). A marker followed only by spaces or commas, such asdisable-line -- why, now disables the whole line. - Coverage capture and the clean check run each source once — they read
sources with
load, so a test's ownrequireran them again: aStructsuperclass raisedsuperclass mismatch, and load-time code ran twice (#122). A mutant of such a class still errors under--strategy reload, which loads the mutated file again;--strategy redefineruns it. Code that guards itself to run once (unless defined?(X)) can now show as covered, so its mutants run where they wereno_coveragebefore. - A red unmutated suite now shows why it failed — in a standalone run, the
Minitest summary or RSpec output of the failing test, with its failure
message, goes to stderr before the "not green" error. A passing run prints
nothing extra. Boot mode (
--rails,--boot) is unchanged (#121).
1.1.0 - 2026-09-28
Added
- Safe-navigation operator (Tier-2, opt-in via
--operators):safe_navigationreplaces&.with.. The mutant survives when no test passesnilto the call. - Range operator (Tier-2, opt-in via
--operators):rangereplaces..with...and...with... The..->...mutant survives when no test checks the last element of the range. Endless ranges (1..) are skipped, because(1..)and(1...)give the same result for slicing,include?,===and pattern matching. - Negation-removal operator (Tier-2, opt-in via
--operators):negation_removalremoves the!from!xand thenotfromnot x. The mutant survives when no test depends on the negated value. The explicit formx.!is skipped, becausex.does not parse.
Changed
- Stderr of tests and specs is visible in the in-process and
--daemonruns. Mutineer silences stdout once per child process and no longer hides stderr, so its own child diagnostics always reach you.--test-commandruns still capture stderr with stdout and show it under--verbose. - A mutant's test run stops at the first failing test — one failure
already kills the mutant, so the forked child does not run the tests that
remain. Killed mutants cost less time, and survived mutants cost the same.
Under Minitest, when the outer reporter of the run records a failure or an
error (a skip does not count), each remaining test and each remaining test
class returns before it starts. A skipped class does not start its
class-level hooks. The run does not unwind: a class that is running
finishes normally, so its
after_allhooks and a class-leveltransaction { super; raise ActiveRecord::Rollback }still run. RSpec runs with--fail-fast. This applies to the in-process backend only: coverage capture and the clean checks still run every test, and the--daemonand--test-commandbackends do not change. The CLI--fail-fastflag keeps its meaning. On rack'slib/rack/utils.rb(--jobs 1), a full run takes about 35–41 s instead of about 86–89 s. With the same coverage map, the verdicts are the same. - The mutant run uses a fixed Minitest seed — with the stop, the test
order can decide the verdict, so the child runs Minitest with seed
1unless the environment setsSEED. The same code then gives the same verdict on each run. Coverage capture and the clean checks keep the random seed, so the clean check runs the tests in a random order while each mutant run uses the fixed order. RSpec keeps its configured order: a suite configured withconfig.order = :randomcan still get a different verdict on each run for the case below. In an order-dependent Minitest suite, the fixed seed makes a falsekilledhappen on every run or on no run, not on some runs. - A mutant whose failing test runs before a hanging test is now
killed, nottimeout— the run stops at the failure, before the hang. The tests did detect the mutation, sokilledis the correct verdict. If the hanging test runs first in the fixed order, the verdict staystimeout. Compared with a baseline from an earlier version, the score usually goes up. In an order-dependent suite it can also go down: a mutant that a random order killed on some runs can survive on every run in the fixed order. A change to the tests can change the fixed order, so a later run can move such a mutant fromkilledtotimeout, and a--baselinegate then reports a score drop. - Some runs still run most tests — Minitest
parallelize_me!, and Railsparallelizeabove its threshold (by default more than 50 tests in the child, or at any test count whenPARALLEL_WORKERSis 2 or more in the environment), queue their tests before the first result comes back, so the queued tests still run. The verdict is the same as before. Below the Rails threshold, the tests run one after the other in the child, and the stop works.
Fixed
- Tests that reopen
$stdout(Minitest'scapture_subprocess_io, RSpec'sto_stdout_from_any_process) no longer make a green suite "not green" or count as false kills. - Test or source files that print while they load no longer make coverage
capture fail with
invalid coverage output. The capture subprocess now sends its result over a separate pipe, not over stdout. - Chain-link operator (Tier-2, opt-in via
--operators):chain_linkdrops one call from a chain, with its arguments and block (user.account.name->user.name). The mutant survives when no test tells the chain apart from the same chain without that step. Conversions and copies (to_s,to_a,dup,freeze, ...) andneware never dropped.
1.0.2 - 2026-09-21
Added
- AI-readable docs wiring: HTML pages with Markdown twins now advertise
rel="alternate" type="text/markdown",index.mdis the landing/CLI essentials twin,skill.mdis listed under Optional inllms.txt, andsitemap.xmlis generated from the same catalog asllms.txt(#91). - Single-source CLI contract: exit codes and
--thresholdlive indocs/fragments/contract.yml;rake docs:generatewritesllms-full.txt,json-schema.html, and the marked copies so they cannot drift (#82). - YARD API on Pages: the current gem's YARD HTML is published at
/api/, linked from the docs site, andrake yard:pages:checkkeeps it from lagging the shipped sources.documentation_uristays the Pages root (#92).
1.0.1 - 2026-09-18
Added
- RubyGems
documentation_uri: the published gem now points at the docs site (https://davidteren.github.io/mutineer/) so gem-page discovery reaches the Pages docs (#90).
Fixed
- A red unmutated suite can no longer pass a mutation gate — coverage capture now keeps the original Minitest/RSpec result, and a failing clean run aborts with the existing smoke-check error (exit 1) instead of scoring those assertion failures as killed mutants. A warm coverage cache re-checks the current suite and cannot bypass this (#96).
- Concurrent external runs no longer restore each other's source files — swap and orphan recovery share one exclusive OS lock per source, acquired before reading or healing. A live owner's mutant and backup stay intact; a dead owner's backup still restores the original bytes (#99).
- Coverage cache now invalidates when a required test helper changes — a successful map records fingerprints of project-local loaded Ruby files, old cache entries without that data rebuild, and a helper-only edit no longer hides a new survivor behind a stale 100% score (#97).
- Release version calculation ignores floating major tags —
release-pr.ymlselects the newest completevMAJOR.MINOR.PATCHancestor and validates the next version before writing files, so a laterv1tag can no longer producev1..1(#95).
1.0.0 - 2026-09-08
The GitHub Action's PR default changes in this release, which is why it is a
new major: workflows pinned to davidteren/mutineer@v0 keep the old full-scan
behavior; upgrading to @v1 opts into diff-scoped PR runs (details under
Changed).
Added
- The Action reports where CI readers look: with the default JSON format it
writes a score/pass-fail table (plus the baseline delta, when
baselineis set) to the job step summary, and emits onefile=…,line=…annotation per surviving mutant (up to 50; the summary lists the first 20, the full set stays in the JSON report) so results land on the PR diff instead of only in a collapsed log group —errorlevel when the gate failed,warningwhen it passed. Whenoutputis unset the JSON report is routed to a temp file and still printed to the log, and thereportoutput now exposes the report path in both cases so a later step can consume the JSON without scraping the log (#86). - Progress during the run: every backend prints
[mutineer] N/M mutants (P%)to stderr at each 10% step, so a long run is never silent between config resolution and the report. Stdout stays byte-exact for--format jsonand--output.WorkerPool#rungains an optionalon_result:callback for this (called in the parent per reaped result) (#86).
Changed
- PR runs scope themselves in the GitHub Action: on
pull_requestevents thesinceinput now defaults to the PR base, so the action grades just the diff out of the box. Migration note (the reason for the major bump): a PR gate that previously full-scanned now scores only the PR's changed lines, sothresholdapplies to fewer mutants. Stay on@v0to keep the old default, or passsince: noneon@v1for full scans. Workflows that already pass a non-emptysinceare unchanged (an explicit empty string is indistinguishable from unset and picks up the new default). A PR with no changed source lines (docs- or test-only) scores zero mutants and passes the scoped gate vacuously; keep a full-scan baseline refresh on main as the backstop. Withuse-bundler: truethe caller's Gemfile picks the gem, and the new default needs mutineer >= 1.0.0 (the action enforces the floor with a clear error). The action scopes to the PR's exact base commit from the event payload (immune to the base branch advancing mid-job), falling back to a fresh fetch of the base branch tip; when neither can be resolved it warns and runs without an action-provided--since(a.mutineer.ymlsince:key, if any, still applies). The default deliberately does NOT fire onpull_request_target: checkout there defaults to the base branch, so auto-scoping would diff the base against itself and green the gate on an empty run (#86). --baselineon a diff-scoped run gates on new survivors only: a--sincerun's score covers only the changed-line mutants, a different denominator from a full-run baseline, so comparing the two scores manufactured false regressions (a 3/4-mutant PR at 75% "dropped" from a 92% whole-repo baseline with zero new survivors). With--since, the score-drop half of the baseline gate is skipped; new-survivor detection by stable id (and the reported before/after scores) are unchanged. The JSON report records the scope in a new additivesummary.scopedkey (schema_version1.3), and thebaselineblock recordsscore_comparableso a consumer knows when not to render the two scores as a comparison (fixed_survivorsis likewise empty under a scoped side: an out-of-scope survivor was never re-tested, so absence does not mean fixed). The reverse direction is a hard guard: a scoped report is refused as a baseline (exit 2 with a regenerate hint), because survivors outside its diff would all read as new regressions. A new--no-sinceflag disables diff scoping explicitly: a typed no beats a.mutineer.ymlsince:key, and the action'ssince: nonepasses it through (#86).
0.11.4 - 2026-07-29
Fixed
--thresholdnow gates on the run being complete, not just its score — a score iskilled / (killed + survived), so mutants that error, time out or come back uncapturable are excluded from the denominator: a broken harness raised the score instead of lowering it. Ninety errored mutants and ten that ran (nine killed) reported 90% and exited 0, so CI could not tell a complete run from a mostly-broken one. Past 10% of attempted mutants producing no verdict, a positive--thresholdnow exits 1 whatever the score, and the report says which states broke, on every--format. A single bad mutant never trips it, however small the run, so a--sincePR with a handful of mutants keeps its flake tolerance (#78).
Added
no_verdict[]in the JSON report (schema_version1.2) — every attempted mutant that produced no verdict, with itsstatusand, where there is one, thedetailsexplaining the cause.Result#detailswas built and rendered in no format at all, so a daemon crash reached the user as nothing but a larger errored count.summarygainsattemptedandno_verdict, the two figures the completeness gate is computed from (#78).
0.11.3 - 2026-07-29
Fixed
- Nothing to mutate now costs nothing —
--sincematching no changed line (a docs-only PR, which README documents--since origin/<base>for) still did the expensive part before discovering there was no work:--daemonbooted the app once for the coverage map and again for every--jobsworker, and--test-commandran the whole suite for a smoke check that calibrates a timeout no mutant would use. Both backends now return as soon as the job list is empty. The in-process path is unchanged: it boots before collecting jobs, so it cannot know the list is empty yet. One consequence worth stating: a zero-job--daemonrun no longer boots the app, so an app that fails to boot now exits 0 (nothing to do) instead of exit 1 (#76). - A dead daemon ends the run instead of scoring the rest against it — a
daemon that dies while running one mutant was already handled:
DaemonClientrespawns and answerserrorfor that mutant. But a daemon that could not come back was not.restart!closes the pipes before respawning, so if the respawn failed at the OS level (EMFILEorENOMEMunder--jobs N,ENOENTwhenbundledoes not resolve) the client was left permanently dead while raising errors that read as ordinary per-mutant failures. Every remaining mutant scorederroragainst nothing, and Mutineer printed a mutation score built on the fraction of mutants that ran before the daemon died.DaemonClientnow raisesDaemonBootErrorwhenever it is gone for good — a refused spawn, a failed boot handshake on respawn, closed pipes, orMAX_RESTARTScrashes — and the CLI reports it as a message and exit 1 rather than a backtrace. Under--jobs Nthe remaining queue is dropped so sibling workers stop too. Errored mutants still cannot fail the--thresholdgate once any mutant is scored (#78).
Changed
- Daemon orchestration split out of
Runner— the persistent-daemon backend (job fan-out, worker DBs, coverage-map build, boot config, verdict mapping) now lives inMutineer::DaemonBackend.Runnerkeeps job collection, coverage selection and the single in-process mutant run, and drops from 662 to 439 lines. The external backend's orchestration stays onRunnerfor now, so the two backends are not yet symmetrical. No CLI or behaviour change (#58). Internal-only removals:Runner.execute_daemon,Runner.daemon_coverage_mapandRunner::DAEMON_TIMEOUTno longer exist. They were never documented — the supported programmatic contract is the JSON report, the stable mutant ids and the exit codes — so only code callingRunnerinternals directly is affected. - Comment diet on orchestration files — present-tense YARD contracts; drop ticket/phase/KTD history tags from runner, CLI, daemon pair, coverage map, reporter, result, and related modules. Safety and score invariants kept (#57).
0.11.2 - 2026-07-24
Fixed
--test-commandunder version managers — scrub Mutineer's rbenv/asdf version bins and bundler/gem env from the child so the suite can resolve the app's Ruby via shims /.ruby-version. Smoke check prints a targeted hint onBundler::RubyVersionMismatchinstead of only blaming DB/migrations. Docs cover a wrapper recipe for stubborn setups (#32).
0.11.1 - 2026-07-23
Fixed
--daemonscore disclosure — stop claiming “no coverage narrowing / lower bound” on every run (narrowing already ships). Warn only when the coverage map is unavailable and the run falls back to the full--testset (#48).--thresholdCI fidelity — exit 1 when a positive threshold is set but the run produced only errors/timeouts/uncapturable (nil score with a broken harness) instead of silently green; pure no_coverage / all-ignored still skips the gate (#49). Non-numeric--threshold/ YAMLthresholdis a usage error (exit 2), not a silent gate-off (#51).--daemoncontracts — reject--framework rspec(exit 2); force--strategy reloadwith a clear warning when redefine was requested (daemon whole-file only) (#50). In-process--railsalways serial unless--daemon(only daemon has per-worker DB isolation) (#55).- Isolation timeout — kill the child process group and honor a clean exit
that races the deadline (not always timeout) (#52). External backend —
signal death is
error, notkilled(#53). --fail-fastis serial on the in-process path (matches daemon) so the survivor set is deterministic under any--jobs(#54).- Dry-run uses
collect_jobsso candidates match a real run (#59). - Daemon schema load once per worker (not every mutant fork) (#56).
- Nested
statement_removalvisits inner statement lists (#62). - Dead seams: remove unused
RailsWorkerDb.provision, simplify parallel daemon fail-fast branch, drop unusedvalidate_daemon!arg (#60). - Archive historical implementation spec under
docs/archive/(#61).
0.11.0 - 2026-07-02
Added
--daemonbackend — fast, parallel-safe Rails mutation testing (#26/#27 Phase 2). Boots the app once in a persistent daemon and forks per mutant (restoring shared-boot speed), and gives each parallel worker its own database so--jobs Nis safe under Rails for the first time — parallel verdicts are proven identical to serial (no fixture cross-talk). Coverage narrowing is restored on this path (each mutant runs only its covering tests; a mutant on an uncovered line isno_coverage), so the daemon score is comparable to the in-process--railsscore. Opt in with--rails --daemon(alsodaemon: truein.mutineer.yml);--daemoncan't be combined with--test-command. SQLite today (hermetic, CI-proven); Postgres per-worker provisioning is in progress (#34/#35). The gem core stays Prism + stdlib, zero runtime dependencies — worker-DB routing uses the app's own ActiveRecord.
0.10.0 - 2026-07-02
Added
--test-commandexternal backend (#27) — mutation-test apps pinned to Ruby < 3.4. Mutineer stays on ≥ 3.4 but runs your suite as a subprocess in the app's own runtime via--test-command "bundle exec rails test %{files}"(%{files}expands to the--testpaths; env is inherited). The mutant is applied on disk with crash-safe backup/restore (self-heals a hard-killed run on next startup); a smoke check aborts before scoring if the unmutated suite isn't green. This path is reload-only, serial (--jobsforced to 1), and does no coverage narrowing — so its score is an upper bound, not comparable to an in-process--railsscore (Mutineer prints this caveat). Also settable astest_command:in.mutineer.yml. Safe parallelism for this path is tracked in #26.
0.9.1 - 2026-07-01
Fixed
- Per-method uncapturable granularity (#25) — the
:uncapturabletaint was whole-file, so a method reachable only by a failed capture in an otherwise- covered file was mislabeledno_coverage. It's now attributed per method (by the method's body coverage), so only the affected method is tainted. Fully- failed files are unchanged.
0.9.0 - 2026-06-30
Added
--fail-fast(#21) — stop at the first surviving mutant; in-flight workers drain, the rest are skipped. Fast red signal for PR gates.--format html(#23) — a single self-contained HTML report (inline CSS, no external assets) with the score, per-source table, and a card per survivor (subject, file:line, operator, stable id, diff).- String, regex, and collection-method operators (#24, Tier-2, opt-in via
--operators):string_literal,regex,collection_method(map↔each,all?↔any?,first↔last,min↔max,select↔reject).
Changed
--dry-runnow honors suppression (#22) — inline# mutineer:disable-lineand.mutineer.ymlignore:entries are omitted from the preview and counted as "ignored (suppressed)", matching a real run.
0.8.0 - 2026-06-30
Fixed
- Singleton methods are now mutated (#20) —
class << selfandmodule_functionmethods were discovered but applied to the instance scope, so the mutant never ran on the singleton the caller dispatches to; every such mutant falsely survived and the file read a false 0%.module_functionmethods are now discovered as singletons, and the redefine strategy re-opensclass << selfso the mutation lands on the called method. (Scores for singleton-heavy files will rise to their true values.) - Write-heavy Rails tests are capturable again (#19) — capture/worker pipes
are
binmode: a binary Marshal payload over a text-mode pipe could raise an encoding error the child then swallowed → empty pipe → false:uncapturable. That was the root cause of the residual write-heavy failures too. A child that dies without writing now also reports how it died (exit status / signal), and--verbosealways surfaces a real reason. Verified on a real Rails app: all 6 previously-uncapturable interactors (incl. caxlsx + Google-client) now capture with real scores, 0 uncapturable.
0.7.1 - 2026-06-30
Added
- GitHub Action (
action.yml, composite) wrapping the CLI for CI — gate a PR on new survivors / score drop withsources,since,baseline,threshold, etc. Inputs are passed viaenv(no${{ }}interpolation into the run script) for command-injection safety. - Docs site (GitHub Pages) with Open Graph / Twitter Card share image; YARD doc comments across the library.
0.7.0 - 2026-06-30
Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
Added
- Equivalent-mutant suppression (#10) — inline
# mutineer:disable-line [ops]and a.mutineer.ymlignore:list keyed on a stable, offset-free mutant id; suppressed mutants are excluded from the score (100% reachable). The stable id (and readable token) is emitted per survivor in JSON. - Source→test auto-pairing + multi-source runs (#11) — pass a directory or
several sources with
--testomitted; tests are inferred by convention (app/,lib/→test/…_test.rb/spec/…_spec.rb) and run under one boot, with per-source results (human + JSONper_source). --baseline <file.json>CI gating (#13) — diff against a prior run by stable id; exit 1 on new survivors or a score drop (with--baseline-epsilon), naming what regressed. Combines with--thresholdvia max exit code.--verbose/--debug(#8) — surface the real error when a fork capture fails.:uncapturablestatus (#9) — distinct fromno_coverage; reported separately ("tests failed to run" vs "genuinely uncovered"). Both excluded from the score.
Fixed
- Fork capture no longer drops fixture transactions (#8) —
reconnectskipsclear_all_connections!when a fixture transaction is open, and stops swallowing the child error; write-heavy Rails tests are mutation-testable again.
Changed
- JSON
schema_version→1.1(additive: survivorid/token,ignored,uncapturable,per_source).
0.6.2 - 2026-06-29
Fixed
--railsdefaultsRAILS_ENVtotest(#7) — an unsetRAILS_ENVbooted development, where the suite isn't loaded, so every mutant was falsely reportedno_coverage(score N/A, exit 0). An explicitRAILS_ENVis respected.--railsdefaults to--jobs 1(#12) — parallel mutant forks share one database and deadlock on transactional fixtures; explicit--jobs Nopts back into parallelism.
Added
- Tier-2 operator discoverability (#14) — the human-format run summary now lists the available opt-in tier-2 operators and how to enable them.
0.6.1 - 2026-06-29
Changed
- Removed
evalentirely — the redefine strategy nowloads the wrapped method snippet from a tempfile instead of evaluating a string. Behavior is identical (top-level load rebuilds the sameModule.nesting), but the gem no longer uses dynamic string execution, clearing supply-chain scanner flags. Zero runtime dependencies unchanged.
0.6.0 - 2026-06-28
Added
- RSpec support (#6) —
--framework rspec(or auto-detected when most--testfiles end in_spec.rb) runs RSpec suites instead of Minitest, via a pluggable test-runner abstraction. Both frameworks are loaded lazily, so Mutineer keeps zero runtime gem dependencies and works in an rspec-only project; coverage selection works for both..mutineer.ymlacceptsframework:.
Fixed
- Redefine strategy keeps compact
class A::Bas a single nesting wrapper (#5) — avoids a constant-resolution disagreement with the reload strategy.
0.5.0 - 2026-06-28
Fixed
class << selfmethods are now discovered and mutated (#3) — previously they were treated as instance methods, so the redefine strategy mis-targeted them.class << other_objblocks are skipped (not representable).- Worker pool no longer deadlocks on large results (#4) — pipes are drained
with
IO.selectand children reaped on EOF, so a result bigger than the OS pipe buffer (~64KB) can't wedge the run.
0.4.0 - 2026-06-28
Added
--since <git-ref>(#2) — mutate only the lines changed since a git ref (e.g.--since origin/main), so CI on a pull request mutation-tests just the new/changed code. Composes with coverage selection;--dry-run --sincenarrows the preview too. Unknown ref / not-a-git-repo exits 2.
0.3.0 - 2026-06-28
Added
- Coverage-guided test selection in boot mode (#1) —
--rails/--bootnow captures coverage by forking the booted app and runs only the test files that cover each mutant's line (uncovered lines reportno_coverage), instead of running every--testfile for every mutant. Cached like standalone mode.
0.2.0 - 2026-06-28
Added
- Boot mode for Rails (and any app needing its environment booted) —
--railsbootsconfig/environmentonce in the parent and forks per mutant (children inherit the booted app), defaults the strategy toredefine, and reconnects ActiveRecord in each fork for DB fork-safety.--boot FILEboots a custom entry point. Boot mode requires--testfiles and runs them for every mutant (coverage-guided selection in boot mode is future work)..mutineer.ymlacceptsboot:andrails:. - GitHub Actions CI (test suite + gem build on Ruby 3.4, ubuntu + macos).
Changed
--strategyvalues are nowreload/redefine(canonical);7a/7bremain accepted as deprecated aliases.
0.1.0 - 2026-06-28
Added
- Initial release of Mutineer — a clean-room, Prism-based mutation-testing tool for Ruby with zero runtime dependencies (Ruby ≥ 3.4).
- Mutation operators: arithmetic, comparison, boolean-connector, boolean-literal,
statement-removal (Tier 1, default); return-nil, literal-mutation,
condition-negation (Tier 2, opt-in via
--operators). - Coverage-guided test selection with a digest-keyed, auto-invalidating cache.
- Fork-isolated, parallel execution (
--jobs) with per-mutant timeouts. - Two application strategies:
reload(whole-file, default) andredefine(surgical), verified to agree on namespaced multi-statement methods. (7a/7baccepted as deprecated aliases.) run,--dry-run,--threshold,--only,--operators,--strategy,--format human|json,--output,--list-operators..mutineer.ymlconfiguration (CLI > config > default precedence).- Byte-correct source handling for multibyte (UTF-8) sources.