Другая версия: English
mutagen-cli
Ваши тесты зелёные. Вот что они не проверяют.
mutagen-cli подсаживает в код правдоподобные баги — off-by-one, забытую инвалидацию кэша, перепутанные аргументы, инвертированные условия — и перезапускает ваш тестовый сьют. Любой баг, который выжил, — дыра в тестах; она репортится как конкретный сценарий отказа, с которым столкнётся пользователь.
В отличие от классического mutation testing, мутанты пишет LLM, которая прочитала и саму функцию, и покрывающие её тесты — она целится в слепые пятна, а не переставляет операторы наугад.
Сделано для ситуации, когда вы (или Claude Code, или Cursor) только что написали кучу кода и кучу тестов к нему, и хочется понять, значат ли эти тесты хоть что-нибудь.
Реальный отчёт — выдержка из прогона на стороннем репозитории (semantic-plagiarism-detector, 44 теста, все зелёные):
Проверено на пяти других реальных проектах
mutagen-cli тестировался не только на фикстурах — на реальные приложения с уже написанными (и зелёными) тестами, без единой правки исходников под инструмент. Три — мои собственные проекты, два — независимые чужие библиотеки:
| Проект | Скоуп | Score | Стоимость |
|---|---|---|---|
| semantic-plagiarism-detector — детектор плагиата | core/ (33 функции, 8 файлов) |
21% (5 killed / 24 viable) | $0.35 |
| cityfeed — телеграм-бот с дайджестом новостей | rank/ (ранжирование) |
20% (5/25) | $0.13 |
| cityfeed — телеграм-бот с дайджестом новостей | dedup/ (дедупликация) |
24% (6/25) | $0.14 |
| CogniWeb_Agent — браузерный LLM-агент | agent/, infrastructure/, utils/ (3 отдельных прогона) |
12% / 5% / 4% | $0.78 |
| parse — обратная функция к str.format, 1.8k★ | parse/__init__.py |
68% (17/25) | $0.13 |
| parsy — парсер-комбинаторы, 451★ | src/parsy/__init__.py |
75% (18/24) | $0.13 |
Во всех четырёх собственных проектах — sub-25% score на коде, который прошёл человеческий ревью и зелёный CI. Типичные дыры: кэш, не различающий ключ (spaCy-пайплайн по языку в plagiarism-detector), перепутанные местами значения (пороги classify), boundary-условия на границах окна (cityfeed dedup), инвертированные проверки безопасности (капча и прокси в CogniWeb). Это не баги, специфичные для одного проекта или стиля кода — это форма слепого пятна, которую юнит-тесты на happy path систематически не видят.
Чтобы проверить это не только на своём коде, mutagen-cli прогонялся и на двух
чужих открытых библиотеках — parse
(обратная функция к str.format(), 1.8k★, MIT) и
parsy (парсер-комбинаторы, 451★,
MIT) — обе с уже зелёными pytest-сьютами, без единой правки исходников.
Score там заметно выше: 68% и 75% против 4–24% на моих проектах — зрелый код
с годами ревью и большим числом контрибьюторов действительно закрывает больше
мутаций, и это ожидаемо: mutation score должен расти вместе с качеством
покрытия, а не быть константой. Но и там находятся настоящие дыры — просто
локализованные, а не размазанные по всему модулю: в parse все 7 живых
мутантов сгруппированы вокруг FixedTzOffset (обработка часовых поясов
системно недопокрыта) плюс один off-by-one для знаковых hex/octal/binary
литералов; в parsy все 6 — в мета-логике отчётов об ошибках (ParseError/
Result: проглоченное исключение, неверная граница, потерянный
furthest-index).
Воспроизвести на встроенном фикстур-проекте: python scripts/benchmark.py
(офлайн, детерминированно, ноль обращений к сети — сеть трогается только с
явным --live).
На том же проекте с мутантами, написанными моделью: 43 мутанта на 15 функциях, 8 killed, 35 survived, 0 неприменимых, из 35 выживших мусорных только 2 (5.7%). Они накрыли 18 из 22 задокументированных слепых пятен теста проекта — и ещё 7, которые не были описаны в его собственных заметках. Полные цифры и оговорки — BENCHMARKS.md.
Живой прогон через OpenRouter API (2026-08-13, --invent включён):
anthropic/claude-sonnet-5 — 40 мутантов, 0% неприменимых, 12.9% мусорных
выживших, $0.26; anthropic/claude-opus-5 — 0% неприменимых, 3.0%
мусорных, 14/22 слепых пятен, $0.68. Подробности —
BENCHMARKS.md, прогон D.
Быстрый старт
pip install mutagen-cli
export OPENROUTER_API_KEY=sk-or-...
mutagen run
Работает на изменённых функциях — на чистом дереве без диффа относительно
main сравнивать не с чем. Для первого знакомства на чистом дереве:
mutagen run --all --max-mutants 5
(mutagen-cli — имя дистрибутива, mutagen сам по себе — библиотека для
аудио-метаданных; устанавливаемая команда называется mutagen.) Чтобы
разрабатывать сам mutagen-cli — git clone https://github.com/Ilyat9/mutagen-cli && cd mutagen-cli && pip install -e ".[dev]".
Готово. Никакого конфига. mutagen run сравнивает рабочее дерево с main,
мутирует только изменённые функции и гоняет только те тесты, которые их
реально покрывают. Если хотите говорить напрямую с Anthropic — см.
Провайдеры.
Провайдеры
mutagen-cli поддерживает два LLM-провайдера, переключается флагом --provider:
OpenRouter (по умолчанию). OpenAI-совместимый шлюз, отдающий те же модели Claude — полезно, потому что API Anthropic обслуживает не все регионы. OpenRouter работает из России без VPN.
- Создайте ключ на https://openrouter.ai/keys.
export OPENROUTER_API_KEY=sk-or-..., либо положите{"openrouter_api_key": "sk-or-..."}в.mutagen/config.json.
Модель по умолчанию: anthropic/claude-sonnet-5 — лучшая точка цена/качество
для генерации мутантов ($2/M input, $10/M output на 2026-08-13).
Переопределяется --model, например --model anthropic/claude-opus-5.
Anthropic. Прямой доступ к API.
export ANTHROPIC_API_KEY=sk-ant-..., либо положите{"anthropic_api_key": "sk-ant-..."}в.mutagen/config.json.- Запуск с
--provider anthropic. Модель по умолчанию:claude-opus-5.
Этот путь менее проверен вживую, чем OpenRouter (все наши живые прогоны — через OpenRouter); при проблемах — заводите issue.
Два нюанса, специфичных для провайдера:
- Модели Claude 5 на OpenRouter по умолчанию гоняются с включённым
reasoning, который засоряет JSON-ответ и раздувает стоимость. mutagen-cli
явно шлёт
reasoning: {"enabled": false}на каждый запрос. Чтобы включить обратно —{"openrouter_reasoning": true}в конфиге. - Параметры сэмплинга (
temperatureи другие) эти модели молча игнорируют, поэтому провайдер OpenRouter их вообще не отправляет.
Стоимость считается из полей usage в ответе API по встроенной таблице цен.
Её можно переопределить или добавить цену для неизвестной модели через
{"prices": {"model/id": [input_per_mtok, output_per_mtok]}} в конфиге; для
модели без известной цены отчёт покажет «cost unavailable», а не $0.
Использование
mutagen run # только то, что изменилось относительно main
mutagen run --base develop # ...относительно другой ветки
mutagen run --path src/billing.py # конкретные файлы или директории
mutagen run --all # весь кодбейз
mutagen run --dry-run # показать план и мэппинг тестов, не тратя денег
Превращаем выживших в тесты:
mutagen run --invent # напечатать тест, который поймал бы каждого выжившего
mutagen run --invent-apply # ...и сохранить проверенные в tests/mutagen_generated/
Каждый предложенный тест проверяется дважды, прежде чем вы его увидите: он обязан проходить на реальном коде и падать на мутанте. Тесты, не прошедшие хотя бы одну проверку, всё равно показываются, но с пометкой — фича не имеет права вам врать.
Для CI:
mutagen run --report-md report.md --report-json report.json --fail-under 70
GitHub Action
action.yml в этом репозитории — мутационный гейт для pull request'ов. Он
мутирует только то, что изменил PR, и постит выживших комментарием, редактируя
один и тот же комментарий на каждый push вместо того, чтобы плодить новые.
name: mutation
on: pull_request
jobs:
mutagen:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e .[dev]
- uses: Ilyat9/mutagen-cli@v0
with:
provider: openrouter # или anthropic
openrouter-api-key: ${{ secrets.OPENROUTER_API_KEY }}
# anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
fail-under: "70"
invent: "true"
Триггер — только pull_request, как в примере выше. Никогда не
pull_request_target с чекаутом кода PR. pull_request_target выполняется
с доступом к секретам base-репозитория, но чекаутит код, который вы не
контролируете — это классический pwn request: PR из форка может как угодно
поменять то, что запускает Action (включая сам тестовый сьют), и утащить
OPENROUTER_API_KEY/ANTHROPIC_API_KEY/GITHUB_TOKEN наружу до того, как
mutagen вообще успеет их вычистить. pull_request этой дыры не имеет: у него
нет доступа к секретам репозитория при запуске на форк-PR.
Тесты из PR запускаются без секретов в окружении: mutagen вычищает
OPENROUTER_API_KEY, ANTHROPIC_API_KEY и GITHUB_TOKEN из окружения
каждого pytest-сабпроцесса, так что код из чужого PR не может их прочитать. Это
защищает от вредоносного теста внутри сьюта, но не от pull_request_target —
на fork-PR всё равно включайте required approval для CI (Settings > Actions >
General > "Require approval for first-time contributors" или строже), как для
любого CI, который гоняет чужой код.
Важные флаги
| Флаг | По умолчанию | |
|---|---|---|
--max-mutants N |
25 | Жёсткий потолок числа генерируемых мутантов. |
--max-files N |
20 | Жёсткий потолок числа рассматриваемых файлов. |
--timeout SECS |
30 | Бюджет времени на мутанта. Автоматически увеличивается, если сьют медленный. |
--workers N |
CPUs/2 | Мутанты гоняются параллельно, каждый — в своей копии репо. |
--provider NAME |
openrouter |
openrouter или anthropic. См. Провайдеры. |
--model ID |
дефолт провайдера | anthropic/claude-sonnet-5 на OpenRouter, claude-opus-5 на Anthropic. |
--effort LEVEL |
medium |
low…max. Ниже — дешевле и быстрее. Только для Anthropic. |
--no-cache |
выкл | Игнорировать дисковый кэш в .mutagen/cache/. |
--python PATH |
venv проекта | Интерпретатор, которым гоняются тесты. |
Ответы LLM кэшируются на диске отдельно на каждую функцию, так что повторный запуск после правки одной функции платит только за неё. Ключ кэша включает набор тестов, показанных модели, — так что изменение покрытия корректно промахивается мимо кэша.
Особенность, о которую легко споткнуться: --max-mutants — общий лимит
на весь запуск, а не на файл. mutagen run --all --path a.py --path b.py --max-mutants 25 сгенерирует до 25 мутантов суммарно на оба файла — если
функций в a.py достаточно, чтобы съесть весь лимит, b.py может не
получить ни одного. Для гарантированного покрытия каждого файла — отдельные
прогоны с --path по одному файлу за раз.
Что значат вердикты
| Вердикт | Значение |
|---|---|
| killed | Тест упал. Хорошо — баг был бы пойман. |
| survived | Все тесты прошли. Это дыра в сьюте. |
| timeout | Мутант, вероятно, создал бесконечный цикл. Считается отдельно, не как kill. |
| survived (unreached) | Выживший более сильного типа: ни один тест вообще не исполняет мутированные строки, так что упасть не могло ничего. Требует карты покрытия; засчитывается как survived. |
| unapplicable | Правку не удалось применить к файлу, либо получившийся код не парсится. Полностью исключается из score. |
| error | pytest не смог запуститься (ошибка сбора, нет тестов). Исключается из score. |
Mutation score — это killed / (killed + survived). Timeout, error и
unapplicable намеренно не входят ни в числитель, ни в знаменатель: засчитывать
их как kill означало бы искусственно завышать score.
Требования
- Python 3.10+
pytest-covв интерпретаторе, которым гоняются тесты — для мэппинга тестов по покрытию. Опционально: без него mutagen-cli откатывается на эвристику и прямо говорит об этом в отчёте.- Проходящий на момент запуска pytest-сьют. mutagen-cli проверяет это первым делом и отказывается работать на красном сьюте, потому что на нём любой мутант выглядел бы «убитым».
- Ключ OpenRouter в
OPENROUTER_API_KEY(получить — https://openrouter.ai/keys, работает из России без VPN), либо ключ Anthropic вANTHROPIC_API_KEYс--provider anthropic. Ключи можно также держать в.mutagen/config.json.
macOS и Linux протестированы. Windows — нет.
Разработка самого mutagen-cli
pip install -e ".[dev]" && pytest && ruff check .
90 тестов, все офлайн и бесплатные: тесты пайплайна гоняют настоящие подпроцессы pytest через replay-провайдер, а тесты CLI заранее засеивают дисковый кэш, так что API-ключ вообще не нужен.
Ограничения
- Только Python и pytest. Другие языки и раннеры не поддерживаются.
- Стоимость реальна. Один вызов LLM на изменённую функцию, плюс ещё один
на каждого выжившего при
--invent. Дисковый кэш делает повторные прогоны дешёвыми, но первый прогон на большом диффе бесплатным не будет.--dry-runпокажет число вызовов заранее. - Эквивалентные мутанты всё равно проскакивают. Промпт активно запрещает мутации, не меняющие поведение, и большинство выживших — реальные баги, но не все. «Survivor» — это наводка для проверки, а не доказанная дыра.
- Точный мэппинг тестов требует
pytest-covв интерпретаторе, которым гоняются тесты. С ним mutagen-cli измеряет, какие тесты исполняют какие строки. Без него — откат на эвристику по имени файла/символу, и отчёт прямо об этом говорит; эвристика, угадавшая не те файлы, репортит мутантов как выживших, хотя тест, который бы их убил, просто не запускался. - Каждый воркер копирует репозиторий во временную директорию. Большие репо с большими неотслеживаемыми директориями это почувствуют.
- Рабочее дерево не трогается никогда — кроме
--invent-apply, который пишет новые файлы вtests/mutagen_generated/и больше никуда. - Это не инструмент покрытия. Высокий mutation score на изменённых вами функциях ничего не говорит о функциях, которые вы не трогали.
- Тексты отчёта генерирует LLM по коду, включая недоверенный. Описание
каждого мутанта и прочие тексты отчёта пишет модель на основе кода функции
(и, при
--invent, её тестов) — это может быть код чужого PR. Эти тексты постятся в markdown/JSON-отчёт и в комментарий к PR как есть, без дополнительной санитизации. Комментарий или докстрока в PR, написанные так, чтобы выглядеть как инструкция модели (prompt injection), в принципе могут повлиять на формулировки в отчёте. Учитывайте это, когда Action гоняется на чужих PR: отчёт — это текст, сгенерированный по недоверенному вводу, а не утверждение от вашей CI-системы.
Как это работает
git diffотносительно merge base → изменённые диапазоны строк (закоммиченное и незакоммиченное, плюс untracked-файлы).astсопоставляет эти строки с целыми функциями, так что модель видит законченные единицы кода.- Ваш сьют один раз гоняется немутированным под
coverageс контекстом на каждый тест. Этот единственный прогон делает две вещи: доказывает, что сьют зелёный, прежде чем тратятся деньги, и строит карту какие тесты исполняют какие строки. Без установленногоpytest-covmutagen-cli откатывается на эвристику по имени файла/символу и помечает отчётmapping: heuristic. - Каждая функция отправляется модели вместе с тестами, которые её реально покрывают, с инструкцией произвести баги, которые эти тесты с наименьшей вероятностью поймают. Ответ ограничен JSON-схемой.
- Мутации приходят как блоки SEARCH/REPLACE (не диффы — модели путаются в
номерах строк). Они применяются сначала точно, затем с нормализацией
пробелов и отступов, затем нечётко через
difflib— и всегда строго в пределах диапазона строк целевой функции, так что блок, встречающийся и в соседней функции, не может незаметно мутировать её вместо нужной. Блоки, которые никуда не встали, или дающие код, который не парсится, помечаютсяunapplicable, а не подгоняются силой. - Каждый мутант гоняется в приватной копии репозитория своего воркера, ровно
против тех тестов, которые исполняют изменённые им строки, с таймаутом.
Мутация на строках, которые не исполняет ни один тест, вообще не
запускается — на неё ничто не могло бы полагаться — и репортится в
отдельной секции
unreached.
История проекта
Мутационное тестирование — старая техника, которой почти никто не
пользуется из-за тысяч тупых мутаций и часов прогона. LLM умеет генерировать
осмысленные, а не случайные мутации — отсюда идея. Существующие аналоги на
момент старта были либо академическими (LLMorpheus), либо закрытыми
корпоративными (Meta ACH, Atlassian), либо решают ту же задачу другим
способом (Mutahunter, ~300
звёзд). По существу отличий от Mutahunter несколько: у них — language-agnostic
LLM-мутации по всему проекту; у mutagen-cli — скоуп по git diff (мутируются
только изменённые функции), coverage-based маппинг тестов на мутацию, а не
эвристика по имени файла, двусторонняя верификация выживших через --invent,
плюс готовый PyPI-пакет и GitHub Action "из коробки".
Что построили
CLI-инструмент mutagen-cli: берёт Python-проект → LLM генерирует 10–30
семантических мутантов (off-by-one, потерянные проверки, перепутанные пороги
— «типичные ошибки вайб-кодера») → применяет каждый к копии репо → прогоняет
только релевантные тесты → отчёт: «ваши тесты зелёные, но вот конкретные
баги, которые они не ловят». Плюс режим --invent: для выживших мутантов
генерирует недостающий тест с двусторонней верификацией.
Ключевые компоненты: diff/AST-скоуп, SEARCH/REPLACE-патчи с четырёхъярусным fuzzy-apply, изолированные worker-копии с параллелизмом, coverage-based маппинг тестов, кэш LLM-ответов, два провайдера (OpenRouter по умолчанию — доступен из РФ, Anthropic опционально), GitHub Action, PyPI-пакет.
Эксперименты: прогнали на реальных проектах
| Проект | Mutation score | Что нашлось |
|---|---|---|
| Полигон (валидация, ground truth) | 43% | метод работает |
| Детектор плагиата (ML) | 21% | пороги сохраняются перепутанными; язык определяется по первой букве |
| cityfeed (ML-лента) | 20% / 24% | guard склейки событий можно обойти; off-by-one в n-граммах |
| CogniWeb_Agent (LLM-агент) | 7% | все три главных мутанта — инверсии проверок безопасности: капча, прокси, невидимые элементы |
Стоимость аудита модуля — $0.13–0.35.
Что узнали по дороге (главная ценность)
1. Инструмент дважды врал, и мы ловили его руками.
- Editable install: мутация применялась в копии, а pytest импортировал оригинал → все «0 из N» по cityfeed были артефактом. Честные цифры после фикса — двузначный процент на каждом модуле.
- Мутация могла попасть не в ту функцию (в
alphaвместоbetaпри похожем коде) → ложные «выжившие». Фикс — привязка apply к строкам целевой функции.
2. Даже стандартные инструменты врут. Coverage на Python 3.12+ с
дефолтным sysmon-ядром молча теряет контексты: в карту попадал только
первый дошедший до строки тест — карта была бы «хуже, чем никакой». Поймали
замером (1 vs 5 контекстов), форсировали ctrace.
3. Байткод-призрак. Мутация min→max не меняла размер файла, CPython
переиспользовал старый .pyc — мутант не прогонялся и записывался
«выжившим». Фикс: PYTHONDONTWRITEBYTECODE=1.
4. Недетерминизм — измерен, а не скрыт. 3 повторных прогона на одном файле: score плавает 24–32%, покрытие функций стабильно 9/9, конкретные мутанты совпадают лишь на 11–22%. Формулировка: «недетерминирован в том, как ломать; детерминирован в том, что тесты не проверяют».
5. Мусорные мутанты — тоже измерены. Junk rate: 12.9% у Sonnet 5, 3.0% у
Opus 5. Эквивалентные мутанты не считаются в score (unapplicable/error/
timeout — отдельные вердикты).
Как преобразился проект
- Из «обёртки над LLM» → в измерительный инструмент. Каждое утверждение подкреплено воспроизводимыми артефактами: BENCHMARKS.md с датированными прогонами (Run A–F), отчёты, регрессионные тесты на каждый найденный баг.
- Из эвристики → в правильную архитектуру. Маппинг тестов по coverage с
nodeid-селекцией вместо догадок по именам файлов; новый класс находок
unreached(«этот код вообще не выполняет ни один тест»). - Из «привязан к Anthropic» → в доступный из РФ. OpenRouter по умолчанию, с учётом ловушек новых моделей (reasoning по умолчанию, молчащие sampling-параметры).
- Из «проверили один раз» → в самопроверяющуюся систему. Философия
«survivor — повод посмотреть, а не доказанная дыра», честная секция
Ограничения, двусторонняя верификация
--invent.
Лицензия
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mutagen_cli-0.1.2.tar.gz.
File metadata
- Download URL: mutagen_cli-0.1.2.tar.gz
- Upload date:
- Size: 165.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7d6083575ea0d91b3042a70b696b15e12076cdbe524878e3a581cf727f0ec581
|
|
| MD5 |
3065ff16806d7e84e8c6c04b6be6edfe
|
|
| BLAKE2b-256 |
585fe75683904d7e7d6b1133dc15766366021c01a5ef8d36deb7751c32fb8a16
|
File details
Details for the file mutagen_cli-0.1.2-py3-none-any.whl.
File metadata
- Download URL: mutagen_cli-0.1.2-py3-none-any.whl
- Upload date:
- Size: 50.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
abaea705836fc041e170cdfd4dbba9604ad97e8a30b54edbead059898f6d75d5
|
|
| MD5 |
24a49804b356cc0f328d199ae0aa0f2b
|
|
| BLAKE2b-256 |
5459953fc4f27351c49ab9d2c79bbb1029afdaf50580e3aee5d9ec2f88fe7c7a
|