148 lines
9.4 KiB
Python
148 lines
9.4 KiB
Python
#!/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())
|