Metadata-Version: 2.5
Name: mutagen-cli
Version: 0.1.4
Summary: Your tests are green. Here's what they don't catch.
Project-URL: Homepage, https://github.com/Ilyat9/mutagen-cli
Project-URL: Issues, https://github.com/Ilyat9/mutagen-cli/issues
Author: mutagen contributors
License-Expression: MIT
License-File: LICENSE
Keywords: claude,llm,mutation-testing,pytest,testing
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Requires-Dist: anthropic>=0.121
Requires-Dist: click>=8.1
Requires-Dist: coverage>=7.0
Requires-Dist: httpx>=0.24
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

Другая версия: [English](README.en.md)

# mutagen-cli

[![CI](https://github.com/Ilyat9/mutagen-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Ilyat9/mutagen-cli/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/mutagen-cli)](https://pypi.org/project/mutagen-cli/)

**Ваши тесты зелёные. Вот что они не проверяют.**

mutagen-cli подсаживает в код правдоподобные баги — off-by-one, забытую
инвалидацию кэша, перепутанные аргументы, инвертированные условия — и
перезапускает ваш тестовый сьют. Любой баг, который выжил, — дыра в тестах;
она репортится как конкретный сценарий отказа, с которым столкнётся
пользователь.

В отличие от классического mutation testing, мутанты пишет LLM, которая
прочитала и саму функцию, и покрывающие её тесты — она целится в слепые пятна,
а не переставляет операторы наугад.

Сделано для ситуации, когда вы (или Claude Code, или Cursor) только что
написали кучу кода и кучу тестов к нему, и хочется понять, значат ли эти тесты
хоть что-нибудь.

Реальный отчёт — выдержка из прогона на стороннем репозитории
([semantic-plagiarism-detector](https://github.com/Ilyat9/semantic-plagiarism-detector),
44 теста, все зелёные):

<img src="assets/mutagen_report.svg" alt="mutagen run: mutation score 21%, два выживших мутанта — кэш spaCy-пайплайна не различает язык, перепутанные местами пороги classify" width="900">

## Проверено на пяти других реальных проектах

mutagen-cli тестировался не только на фикстурах — на реальные приложения с
уже написанными (и зелёными) тестами, без единой правки исходников под
инструмент. Три — мои собственные проекты, два — независимые чужие
библиотеки:

| Проект | Скоуп | Score | Стоимость |
| --- | --- | ---: | ---: |
| [semantic-plagiarism-detector](https://github.com/Ilyat9/semantic-plagiarism-detector) — детектор плагиата | `core/` (33 функции, 8 файлов) | **21%** (5 killed / 24 viable) | $0.35 |
| [cityfeed](https://github.com/Ilyat9/cityfeed) — телеграм-бот с дайджестом новостей | `rank/` (ранжирование) | **20%** (5/25) | $0.13 |
| [cityfeed](https://github.com/Ilyat9/cityfeed) — телеграм-бот с дайджестом новостей | `dedup/` (дедупликация) | **24%** (6/25) | $0.14 |
| [CogniWeb_Agent](https://github.com/Ilyat9/CogniWeb_Agent) — браузерный LLM-агент | `agent/`, `infrastructure/`, `utils/` (3 отдельных прогона) | 12% / 5% / 4% | $0.78 |
| [parse](https://github.com/r1chardj0n3s/parse) — обратная функция к str.format, 1.8k★ | `parse/__init__.py` | **68%** (17/25) | $0.13 |
| [parsy](https://github.com/python-parsy/parsy) — парсер-комбинаторы, 451★ | `src/parsy/__init__.py` | **75%** (18/24) | $0.13 |

<img src="assets/mutagen_report_cityfeed.svg" alt="mutagen run: cityfeed dedup, mutation score 24%, два выживших мутанта — guard склейки событий можно обойти, off-by-one в n-граммах" width="900">

Во всех четырёх собственных проектах — sub-25% score на коде, который прошёл
человеческий ревью и зелёный CI. Типичные дыры: кэш, не различающий ключ
(spaCy-пайплайн по языку в plagiarism-detector), перепутанные местами значения
(пороги classify), boundary-условия на границах окна (cityfeed dedup),
инвертированные проверки безопасности (капча и прокси в CogniWeb). Это не
баги, специфичные для одного проекта или стиля кода — это форма слепого
пятна, которую юнит-тесты на happy path систематически не видят.

Чтобы проверить это не только на своём коде, mutagen-cli прогонялся и на двух
чужих открытых библиотеках — [parse](https://github.com/r1chardj0n3s/parse)
(обратная функция к `str.format()`, 1.8k★, MIT) и
[parsy](https://github.com/python-parsy/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](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](BENCHMARKS.md), прогон D.

## Быстрый старт

```bash
pip install mutagen-cli
```

```bash
export OPENROUTER_API_KEY=sk-or-...
```

```bash
mutagen run
```

Работает на изменённых функциях — на чистом дереве без диффа относительно
`main` сравнивать не с чем. Для первого знакомства на чистом дереве:

```bash
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` сравнивает рабочее дерево с
дефолтной веткой репозитория (автоопределяется через `origin/HEAD`, с
откатом на локальные `main`/`master`), мутирует только изменённые функции и
гоняет только те тесты, которые их реально покрывают. Если ваша дефолтная
ветка называется иначе (`develop`, `trunk`, ...) и автоопределение не
сработало — укажите её явно: `mutagen run --base master` (или `develop`,
`trunk` — что у вас за ветка). Если хотите говорить напрямую с Anthropic —
см. [Провайдеры](#провайдеры).

## Провайдеры

mutagen-cli поддерживает два LLM-провайдера, переключается флагом `--provider`:

**OpenRouter (по умолчанию).** OpenAI-совместимый шлюз, отдающий те же модели
Claude — полезно, потому что API Anthropic обслуживает не все регионы.
OpenRouter работает из России без VPN.

1. Создайте ключ на <https://openrouter.ai/keys>.
2. `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.

1. `export ANTHROPIC_API_KEY=sk-ant-...`, либо положите
   `{"anthropic_api_key": "sk-ant-..."}` в `.mutagen/config.json`.
2. Запуск с `--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.

## Использование

```bash
mutagen run                          # только то, что изменилось относительно main
mutagen run --base develop           # ...относительно другой ветки
mutagen run --path src/billing.py    # конкретные файлы или директории
mutagen run --all                    # весь кодбейз
mutagen run --dry-run                # показать план и мэппинг тестов, не тратя денег
```

Превращаем выживших в тесты:

```bash
mutagen run --invent          # напечатать тест, который поймал бы каждого выжившего
mutagen run --invent-apply    # ...и сохранить проверенные в tests/mutagen_generated/
```

Каждый предложенный тест проверяется дважды, прежде чем вы его увидите: он
обязан проходить на реальном коде и падать на мутанте. Тесты, не прошедшие
хотя бы одну проверку, всё равно показываются, но с пометкой — фича не имеет
права вам врать.

Для CI:

```bash
mutagen run --report-md report.md --report-json report.json --fail-under 70
```

### GitHub Action

`action.yml` в этом репозитории — мутационный гейт для pull request'ов. Он
мутирует только то, что изменил PR, и постит выживших комментарием, редактируя
один и тот же комментарий на каждый push вместо того, чтобы плодить новые.

```yaml
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"
```

⚠️ **Важно:** `pip install` выполняет код из PR (setup.py, build-хуки) в основном процессе раннера до того, как mutagen вычистит секреты, поэтому для репозиториев, принимающих PR от форков, обязателен "Require approval for first-time contributors" в Settings → Actions → General.

**Триггер — только `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.

**Важный нюанс про `error`.** Сюда попадает в том числе мутация, которая
ломает module-level код или декоратор целевой функции настолько, что pytest
падает с ошибкой сбора (collection error, обычно exit code 2) ещё до запуска
тестов. Это реальный, пойманный тестами баг — но раз он не попадает ни в
killed, ни в survived, он исключён из знаменателя score и никак не влияет на
итоговый процент. Логика сделана так намеренно (score должен отражать
качество *исполненных* тестов, а не крах инфраструктуры сбора), но это
означает, что такие мутанты стоит просматривать в отчёте отдельно, а не
полагаться на то, что 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

```bash
pip install -e ".[dev]" && pytest && ruff check .
```

100+ офлайн-тестов, все бесплатные: тесты пайплайна гоняют настоящие
подпроцессы 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 с санитизацией от
  prompt-injection векторов (image-beacon синтаксис, HTML-теги). Но комментарий
  или докстрока в PR, написанные так, чтобы влиять на модель, в принципе могут
  изменить формулировки в отчёте. Учитывайте это, когда Action гоняется на
  чужих PR: отчёт — это текст, сгенерированный по недоверенному вводу, а не
  утверждение от вашей CI-системы.
- **Проекты, чей пакет импортируется только из site-packages** (например, с
  C-расширениями, собранными при установке), пока не подходят: mutagen-cli
  ставит исходники воркер-копии вперёд на `PYTHONPATH`, а собранных модулей в
  копии нет — baseline упадёт с `ImportError`.
- **Strip-лист секретов из окружения pytest-сабпроцессов фиксированный** —
  `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`. Другие токены
  (`AWS_*`, `NPM_TOKEN` и т.п.) вычищаются не автоматически. Не запускайте на
  недоверенном коде с такими переменными в окружении.

## Как это работает

1. `git diff` относительно merge base → изменённые диапазоны строк
   (закоммиченное и незакоммиченное, плюс untracked-файлы).
2. `ast` сопоставляет эти строки с целыми функциями, так что модель видит
   законченные единицы кода.
3. Ваш сьют один раз гоняется немутированным под `coverage` с контекстом на
   каждый тест. Этот единственный прогон делает две вещи: доказывает, что
   сьют зелёный, прежде чем тратятся деньги, и строит карту **какие тесты
   исполняют какие строки**. Без установленного `pytest-cov` mutagen-cli
   откатывается на эвристику по имени файла/символу и помечает отчёт
   `mapping: heuristic`.
4. Каждая функция отправляется модели вместе с тестами, которые её реально
   покрывают, с инструкцией произвести баги, которые эти тесты с наименьшей
   вероятностью поймают. Ответ ограничен JSON-схемой.
5. Мутации приходят как блоки SEARCH/REPLACE (не диффы — модели путаются в
   номерах строк). Они применяются сначала точно, затем с нормализацией
   пробелов и отступов, затем нечётко через `difflib` — и всегда **строго в
   пределах диапазона строк целевой функции**, так что блок, встречающийся и
   в соседней функции, не может незаметно мутировать её вместо нужной. Блоки,
   которые никуда не встали, или дающие код, который не парсится, помечаются
   `unapplicable`, а не подгоняются силой.
6. Каждый мутант гоняется в приватной копии репозитория своего воркера, ровно
   против тех тестов, которые исполняют изменённые им строки, с таймаутом.
   Мутация на строках, которые **не** исполняет ни один тест, вообще не
   запускается — на неё ничто не могло бы полагаться — и репортится в
   отдельной секции `unreached`.

## Сравнение с другими инструментами

| | mutmut | cosmic-ray | Mutahunter | mutagen-cli |
| --- | --- | --- | --- | --- |
| Подход к мутациям | regex/AST-паттерны (фиксированный набор операторов) | regex/AST-паттерны (фиксированный набор операторов) | LLM, по всему проекту | LLM, только по diff-скоупу |
| Двусторонняя верификация* | нет | нет | нет | да, режим `--invent` |
| Маппинг тестов на мутацию | по coverage (`pytest-cov`) | по coverage (`pytest-cov`) | эвристика/полный сьют | по coverage (`pytest-cov`), с откатом на эвристику по имени файла |
| Языки | Python | Python | language-agnostic | Python |

\* «Двусторонняя верификация» — это когда для выжившего мутанта не просто
предлагается тест, а сгенерированный тест реально прогоняется дважды: должен
пройти на исходном коде и упасть на мутанте. Само по себе автогенерирование
тестов для повышения покрытия не уникально — это умеет и Mutahunter; уникальна
именно проверка, что предложенный тест действительно ловит конкретный баг, а
не просто выглядит правдоподобно.

## История проекта

Мутационное тестирование — старая техника, которой почти никто не
пользуется из-за тысяч тупых мутаций и часов прогона. LLM умеет генерировать
осмысленные, а не случайные мутации — отсюда идея. Существующие аналоги на
момент старта были либо академическими (LLMorpheus), либо закрытыми
корпоративными (Meta ACH, Atlassian), либо решают ту же задачу другим
способом ([Mutahunter](https://github.com/codeintegrity-ai/mutahunter), ~300
звёзд, тоже LLM-based и тоже умеет генерировать тесты для непокрытых веток).
Отличия от Mutahunter — не в самой идее LLM-мутаций или автогенерации тестов,
а в скоупе (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%** | все три главных мутанта — инверсии проверок безопасности: капча, прокси, невидимые элементы |
| [parse](https://github.com/r1chardj0n3s/parse) — обратная к str.format | 68% | boundary-условия на часовых поясах (`FixedTzOffset`); off-by-one в hex/octal/binary литералах |
| [parsy](https://github.com/python-parsy/parsy) — парсер-комбинаторы | 75% | мета-логика ошибок (`ParseError`/`Result`): проглоченное исключение, неверная граница, потерянный index |

Стоимость аудита модуля — $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](BENCHMARKS.md) с
  датированными прогонами (Run A–G), отчёты, регрессионные тесты на каждый
  найденный баг.
- **Из эвристики → в правильную архитектуру.** Маппинг тестов по coverage с
  nodeid-селекцией вместо догадок по именам файлов; новый класс находок
  `unreached` («этот код вообще не выполняет ни один тест»).
- **Из «привязан к Anthropic» → в доступный из РФ.** OpenRouter по умолчанию,
  с учётом ловушек новых моделей (reasoning по умолчанию, молчащие
  sampling-параметры).
- **Из «проверили один раз» → в самопроверяющуюся систему.** Философия
  «survivor — повод посмотреть, а не доказанная дыра», честная секция
  [Ограничения](#ограничения), двусторонняя верификация `--invent`.

## Лицензия

MIT
