#!/usr/bin/env python3 """Плант мутаций в КОПИЮ дерева зоны, с вердиктом по ТЕКСТУ падения НАЗВАННОГО пина. Зачем он в репозитории, а не в скретчпаде: он производит числа, которые едут в акт приёмки («посадок N, выживших M»), а число без прибора, которым оно снято, пере-снять нечем. Урок 11.09: записи этого пака одну смену прожили в скретчпаде, и пере-проверить их было не на чем. ⛔ ЧЕТЫРЕ УСЛОВИЯ ЗАСЧЁТА, и каждое поставлено против своего способа получить ложное число. Первые два были здесь до ряда 486, вторые два — его предмет (единый бэклог, строка 486): 1. Пакеты идут СПИСКОМ аргументов. Первая редакция передавала `pkg` одной строкой: два пакета через пробел превращались в один неразрешимый аргумент, `go test` выходил ненулевым — и харнесс читал это как поимку. Три записи получили ложное RED, нашла их ручная пере-проверка. 2. Ненулевой выход, в котором нет ни одной строки `--- FAIL`, — НЕ ИЗМЕРЕНО, а не поймано: у сломанной команды и у пойманной мутации один и тот же код возврата. 3. **ЗЕЛЁНЫЙ БАЗОВЫЙ ПРОГОН — УСЛОВИЕ.** Пакет, красный по своей причине, отчитается КАЖДОЙ посадкой как о поимке, и «посадок N, поймано M» станет утверждением о другом предмете. База снимается ОДИН раз на пару (пакеты, `-run`) и запоминается: на том же наборе она не меняется. 4. **ЗАСЧИТЫВАЕТСЯ ПАДЕНИЕ ИМЕННО НАЗВАННОГО ПИНА.** Правый вердикт по неправой причине — дыра, а не поимка, и от поимки отличается только тем, прочёл ли кто-нибудь ТЕКСТ. Поле `pin` обязательно: запись без него НЕ ИЗМЕРЕНА, потому что «покраснело хоть что-то» — это ответ на соседний вопрос. ⚠ И ПЯТОЕ, ПРО ПРИБОР, А НЕ ПРО ЗАСЧЁТ: тесты гоняются **с `-race`**, потому что батарея зоны гоняет с ней (`make check`: `go test ./... -race -count=1`). Без гонки мутация, которую ловит ТОЛЬКО детектор гонок, читается как выжившая — то есть инструмент объявил бы дыру там, где приёмка её не увидит. Цена названа: прогон медленнее, и на больших пакетах это заметно. Формат каталога — JSON-массив записей: {"id", "file", "pkg", "run", "pin", "what", "old", "new"} `file` и `pkg` — относительно корня зоны; `old` обязан встречаться в файле РОВНО один раз; `run` — шаблон `-run`; `pin` — имя теста, чьё падение единственное считается поимкой. Вызов: python3 tools/mutate.py <каталог.json> <корень копии> [<корень оригинала>] Третий аргумент — откуда восстанавливать файлы после каждой посадки; по умолчанию корень зоны. Он существует потому, что у самого инструмента есть пин: он гоняется против фикстурного дерева в `internal/gates/testdata/mutantfixture`, у которого свой оригинал (`TestTheMutationHarnessCounts…`). ⚠ Корень копии обязан быть КОПИЕЙ (`cp -a --parents platform docs/architecture/14-api-contract …`): скрипт правит файлы на месте и восстанавливает их из оригинала после каждой записи. """ import json import os import shutil import subprocess import sys ORIGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) CAUGHT, SURVIVED, UNMEASURED = "RED", "ВЫЖИЛА", "НЕ ИЗМЕРЕНА" def restore(root: str, rel: str) -> None: shutil.copy2(os.path.join(ORIGIN, rel), os.path.join(root, rel)) def plant(root: str, rel: str, old: str, new: str) -> str: path = os.path.join(root, rel) body = open(path, encoding="utf-8").read() seen = body.count(old) if seen != 1: return f"якорь найден {seen} раз(а), нужен ровно один" open(path, "w", encoding="utf-8").write(body.replace(old, new, 1)) return "" def run(root: str, pkg: str, pattern: str): # ⛔ СПИСКОМ, а не строкой (условие 1); -race — потому что батарея с гонкой (условие 5). cmd = ["go", "test", *pkg.split(), "-race", "-count=1", "-run", pattern] p = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=3600) return p.returncode, p.stdout + p.stderr def fails(out: str): return [l for l in out.splitlines() if l.startswith("--- FAIL")] def baseline(root: str, pkg: str, pattern: str, cache: dict): """Зелёный базовый прогон этого набора — условие 3. Снимается один раз на набор.""" key = (pkg, pattern) if key not in cache: code, out = run(root, pkg, pattern) why = "" if code != 0: named = fails(out) or [l for l in out.splitlines() if ".go:" in l][:2] why = "базовый прогон набора НЕ ЗЕЛЁНЫЙ до всякой посадки: " + ( named[0].strip()[:160] if named else f"exit {code} без строк падения") cache[key] = why return cache[key] def verdict(record, code: int, out: str): """Какой исход у одной посадки, и почему именно такой.""" pin = record.get("pin", "") if not pin: return UNMEASURED, "у записи нет поля `pin`: «покраснело хоть что-то» — ответ на соседний вопрос" if code == 0: return SURVIVED, "прогон зелёный" got = fails(out) if not got: # Условие 2: у битой команды и у пойманной мутации один код возврата. broken = [l for l in out.splitlines() if ".go:" in l][:2] return UNMEASURED, f"exit {code} без единой строки `--- FAIL`" + ( f": {broken[0].strip()[:160]}" if broken else "") # Условие 4: красное НЕ ТЕМ тестом — это дыра, а не поимка. if not any(l.startswith(f"--- FAIL: {pin}") for l in got): return UNMEASURED, ("покраснело, но НЕ названным пином — упало: " + ", ".join(l.split()[2] for l in got[:3]) + f"; ожидался {pin}") return CAUGHT, f"--- FAIL: {pin}" def main() -> int: global ORIGIN catalogue, root = sys.argv[1], os.path.abspath(sys.argv[2]) if len(sys.argv) > 3: ORIGIN = os.path.abspath(sys.argv[3]) if not os.path.isfile(os.path.join(root, "go.mod")): print(f"{root} не похож на корень зоны (нет go.mod) — отказ", file=sys.stderr) return 2 if os.path.realpath(root) == os.path.realpath(ORIGIN): print("корень копии совпадает с рабочим деревом — отказ: мутации сажаются только в КОПИЮ", file=sys.stderr) return 2 tally = {CAUGHT: 0, SURVIVED: 0, UNMEASURED: 0} bases: dict = {} for m in json.load(open(catalogue, encoding="utf-8")): restore(root, m["file"]) if why := baseline(root, m["pkg"], m["run"], bases): print(f"=== {m['id']}: ⚠ {UNMEASURED} — {why}") tally[UNMEASURED] += 1 continue if why := plant(root, m["file"], m["old"], m["new"]): print(f"=== {m['id']}: ⚠ {UNMEASURED} — НЕ ПОСАЖЕНА: {why}") tally[UNMEASURED] += 1 restore(root, m["file"]) continue code, out = run(root, m["pkg"], m["run"]) got, why = verdict(m, code, out) mark = {CAUGHT: "", SURVIVED: "⛔ ", UNMEASURED: "⚠ "}[got] print(f"=== {m['id']}: {mark}{got} — {m['what']} [{why}]") tally[got] += 1 restore(root, m["file"]) total = sum(tally.values()) print(f"\nитог: записей {total} · поймано {tally[CAUGHT]} · " f"ВЫЖИЛО {tally[SURVIVED]} · НЕ ИЗМЕРЕНО {tally[UNMEASURED]}") return 0 if __name__ == "__main__": sys.exit(main())