Metadata-Version: 2.5
Name: mutagen-cli
Version: 0.1.0
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.40
Requires-Dist: click>=8.1
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

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

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 |

<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 систематически не видят.

Воспроизвести на встроенном фикстур-проекте: `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.

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

Пока не в PyPI — ставится из исходников. Имя дистрибутива зарезервировано как
`mutagen-cli` (`mutagen` — это библиотека для аудио-метаданных), команда —
`mutagen`.

```bash
git clone https://github.com/Ilyat9/mutagen-cli && cd mutagen-cli && pip install -e .
```

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

```bash
mutagen run
```

Готово. Никакого конфига. `mutagen run` сравнивает рабочее дерево с `main`,
мутирует только изменённые функции и гоняет только те тесты, которые их
реально покрывают. Если хотите говорить напрямую с 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`.

Два нюанса, специфичных для провайдера:

- Модели 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"
```

### Важные флаги

| Флаг | По умолчанию | |
| --- | --- | --- |
| `--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

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

74 теста, все офлайн и бесплатные: тесты пайплайна гоняют настоящие
подпроцессы pytest через replay-провайдер, а тесты CLI заранее засеивают
дисковый кэш, так что API-ключ вообще не нужен.

## Ограничения

- **Только Python и pytest.** Другие языки и раннеры не поддерживаются.
- **Стоимость реальна.** Один вызов LLM на изменённую функцию, плюс ещё один
  на каждого выжившего при `--invent`. Дисковый кэш делает повторные прогоны
  дешёвыми, но первый прогон на большом диффе бесплатным не будет.
  `--dry-run` покажет число вызовов заранее.
- **Эквивалентные мутанты всё равно проскакивают.** Промпт активно запрещает
  мутации, не меняющие поведение, и большинство выживших — реальные баги, но
  не все. «Survivor» — это наводка для проверки, а не доказанная дыра.
- **Точный мэппинг тестов требует `pytest-cov`** в интерпретаторе, которым
  гоняются тесты. С ним mutagen-cli измеряет, какие тесты исполняют какие строки.
  Без него — откат на эвристику по имени файла/символу, и отчёт прямо об этом
  говорит; эвристика, угадавшая не те файлы, репортит мутантов как выживших,
  хотя тест, который бы их убил, просто не запускался.
- **Каждый воркер копирует репозиторий** во временную директорию. Большие
  репо с большими неотслеживаемыми директориями это почувствуют.
- **Рабочее дерево не трогается никогда** — кроме `--invent-apply`, который
  пишет новые файлы в `tests/mutagen_generated/` и больше никуда.
- **Это не инструмент покрытия.** Высокий mutation score на изменённых вами
  функциях ничего не говорит о функциях, которые вы не трогали.

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

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`.

## История проекта: от идеи до готового инструмента

### Исходная точка

Задача была не «сделать проект», а «найти идею, которая зацепит программистов
и даст статью на Хабр + звёзды». Проанализировали, что реально вирусится на
Reddit, HN и Хабре в 2025–2026: главный тренд — недоверие к ИИ-коду (46%
разработчиков не доверяют выводу LLM), вирусные истории про «зелёные тесты на
нерабочем коде», ноющие треды про вайб-код PR, которые никто не хочет
ревьюить.

Из этого родилась идея: **LLM-driven mutation testing** — симбиоз старой
уважаемой техники (мутационное тестирование, которым почти никто не
пользуется из-за тысяч тупых мутаций и часов прогона) и LLM (которая идеально
генерирует осмысленные баги). Проверка показала: аналоги либо мёртвые
(Mutahunter, заброшен), либо академические (LLMorpheus, архив), либо закрытые
корпоративные (Meta ACH, Atlassian). Свободная ниша с научным подтверждением
эффективности LLM-мутантов.

### Что построили

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

### Сюжет

Не «я сделал тулзу», а: **«Я — самый педантичный вайб-кодер из возможных
(калибровки, CI, ablation-таблицы). Я построил измеритель и проверил себя.
Тесты ловят треть багов. А по пути мой собственный инструмент дважды меня
обманул — и я ловил его руками, потому что доверие надо проверять на каждом
уровне.»**

## Лицензия

MIT
