textmachine/platform/tools/mutate.py

148 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())