textmachine/eval/conformance.py

175 lines
11 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
"""СВЕРКА С БУКВОЙ ЗАКАЗА. $0. Инструмент зоны `eval/`, не одного пака.
Зачем он есть. Эксп-22 сдал отчёт, у которого ВСЕ числа сходились с сырьём (два гейта, 85
проверок, пере-счёт двумя путями) — и при этом ШЕСТЬ требований промта не были выполнены. Числа и
заказ — разные классы отказа, и прибор для первого не ловит второй. Все шесть пропусков стояли не
в нумерованных блоках фаз, а в придаточных, и «критик полноты», написанный тем же, кто вёл пак,
их не поймал: он проверял по МОДЕЛИ заказа, а не по заказу.
Что делает. Читает из markdown-документа таблицу требований (маркер `<!-- ТРЕБОВАНИЯ -->`) и по
КАЖДОЙ строке проверяет улику исполнения. Улика бывает двух видов:
`$ <команда>` — команда должна завершиться кодом 0 (только $0-команды; сеть и покупки здесь
неуместны и не предусмотрены);
`<путь>` — артефакт должен существовать и быть непустым.
Улика `—` означает «улики нет»: в режиме плана это законно (требование ещё не исполнено), в
режиме сдачи — отказ.
Режимы:
--plan требование без улики = ОЖИДАЕТ (код 0, если ни одна улика не ПАДАЕТ);
--final требование без улики = ПРОВАЛ (код ≠0). Гонять перед сдачей пака.
Запуск: eval/.venv/bin/python eval/conformance.py <файл.md> [--plan|--final]
⚠ Границы инструмента, чтобы клейм не был шире прибора. Он проверяет, что у требования ЕСТЬ
проходящая улика, а не что улика доказывает именно это требование: связь «требование ↔ улика»
устанавливает автор таблицы, и подменить её ничего не мешает. Это сдвигает работу с «вспомнить всё
из прозы» на «объявить улику заранее» — но не отменяет чтения.
⚠⚠ **ДВА ДЕФЕКТА, НАЙДЕННЫЕ ПРИЁМКОЙ НА ПАКЕ 23, ЧИНЯТСЯ ЗДЕСЬ** (фаза Д, 10.08):
1. **Реестр жил в эфемерном scratchpad.** `--final` печатал зелёное против таблицы, которой в
репозитории нет: читатель пере-снять её не мог, а автор мог править её после результата. Теперь
в режиме `--final` источник ОБЯЗАН быть отслежен git и совпадать с закоммиченным (тот же
фриз-гейт, что у кассы `tenant_panel/money.py`). Обход — `TM_ALLOW_UNFROZEN=1`, громко.
2. **Отчёт проверял сам себя.** Большая часть улик была грепом формулировок ТОГО ЖЕ документа, то
есть «в тексте написано, что сделано» выдавалось за «сделано». Механически отличить «улика на
печать» от «улики на дело» нельзя, поэтому инструмент их СЧИТАЕТ и печатает долю: улика,
упоминающая сам файл реестра или файл отчёта, помечается `сам-себя` и выносится в итог
отдельной строкой. Ноль таких улик не требуется — требуется, чтобы читатель видел их число.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
MARK = "<!-- ТРЕБОВАНИЯ -->"
def frozen(src: Path) -> tuple[bool, str]:
"""Реестр отслежен git и совпадает с закоммиченным? Это и есть его фриз."""
try:
rel = str(src.resolve().relative_to(REPO))
except ValueError:
return False, f"реестр вне репозитория: {src}"
r = subprocess.run(["git", "-C", str(REPO), "ls-files", "--error-unmatch", rel], # noqa: S603,S607
capture_output=True, text=True)
if r.returncode:
return False, f"реестр НЕ в git: {rel} — гейт проверял бы файл, которого нет у читателя"
d = subprocess.run(["git", "-C", str(REPO), "diff", "--quiet", "HEAD", "--", rel]) # noqa: S603,S607
if d.returncode:
return False, f"реестр отличается от закоммиченного: {rel}"
return True, ""
def rows(md: str) -> list[tuple[str, str, str]]:
"""Строки таблицы требований: (ID, требование, улика)."""
if MARK not in md:
raise SystemExit(f"в документе нет маркера {MARK} — реестр требований не объявлен")
tail = md.split(MARK, 1)[1]
out = []
for line in tail.splitlines():
s = line.strip()
if not s.startswith("|"):
if out: # таблица кончилась
break
continue
cells = [c.strip() for c in s.strip("|").split("|")]
if len(cells) < 3 or set(cells[0]) <= set("-: "):
continue
if cells[0].upper() in ("ID", ""):
continue
out.append((cells[0], cells[1], cells[2]))
if not out:
raise SystemExit("реестр требований пуст")
return out
def check(ev: str) -> tuple[str, str]:
"""Вернуть (статус, деталь). Статусы: OK · ПРОВАЛ · ОЖИДАЕТ."""
ev = ev.strip().strip("`")
if ev in ("", "-", ""):
return "ОЖИДАЕТ", "улика не объявлена"
if ev.startswith("$"):
cmd = ev[1:].strip()
# ⚠ Улика не имеет права звать этот же инструмент в режиме сверки: реестр, чьей уликой
# служит проверка самого реестра, уходит в бесконечную рекурсию. Поймано первым прогоном.
if "conformance.py" in cmd and ("--plan" in cmd or "--final" in cmd):
return "ПРОВАЛ", "улика рекурсивна: зовёт conformance.py в режиме сверки"
try:
r = subprocess.run(cmd, shell=True, cwd=REPO, capture_output=True, # noqa: S602
text=True, timeout=1800)
except subprocess.TimeoutExpired:
return "ПРОВАЛ", "таймаут"
if r.returncode == 0:
return "OK", ""
return "ПРОВАЛ", (r.stderr or r.stdout).strip().splitlines()[-1][:120] if (
r.stderr or r.stdout).strip() else f"код {r.returncode}"
p = (REPO / ev) if not ev.startswith("/") else Path(ev)
if p.is_dir():
return ("OK", "") if any(p.iterdir()) else ("ПРОВАЛ", "каталог пуст")
if p.exists() and p.stat().st_size > 0:
return "OK", ""
return "ПРОВАЛ", "артефакта нет или он пуст"
def main() -> int:
args = [a for a in sys.argv[1:] if a]
if not args:
print(__doc__)
return 1
mode = "--final" if "--final" in args else "--plan"
src = next((a for a in args if not a.startswith("--")), None)
if not src:
print("не указан файл")
return 1
path = REPO / src if not src.startswith("/") else Path(src)
md = path.read_text(encoding="utf-8")
bad = pend = selfref = 0
print(f"СВЕРКА С БУКВОЙ ЗАКАЗА: {src} [{mode[2:]}]\n")
# ⚠ ФРИЗ РЕЕСТРА. Гейт против незакоммиченной таблицы ничего не доказывает: её можно дописать
# ПОД результат, и читатель этого не увидит. Пак 23 прошёл `--final` против реестра, лежавшего
# в сессионном scratchpad, — и «соответствие заказу проверено» из этого не следовало.
if mode == "--final":
ok, why = frozen(path)
if not ok:
if os.environ.get("TM_ALLOW_UNFROZEN") == "1":
print(f"⚠ ФРИЗ-ГЕЙТ РЕЕСТРА ОТКЛЮЧЁН (TM_ALLOW_UNFROZEN=1): {why}\n")
else:
print(f"{why}\n зафризь реестр коммитом ДО сверки (промт фазы Д, нормы рига)")
return 1
# Улика, которая грепает сам реестр/отчёт, доказывает «напечатано», а не «сделано».
# ⚠ Первая редакция искала имя как ОТДЕЛЬНОЕ слово с запретом «/» слева — то есть любая
# улика с ПУТЁМ (а иначе улику не пишут) была невидима, и строка «сам-себя: 0» печаталась
# всегда. Поймано ревью исполнением. Ищем подстроку имени, плюс имена ОТЧЁТОВ фазы: улика,
# грепающая отчёт, доказывает «напечатано», а не «сделано».
selfnames = {path.name, *(Path(a).name for a in args if a.endswith(".md"))}
selfnames |= {f.name for f in (REPO / "docs" / "experiments").glob("2[23]-*.md")}
for rid, req, ev in rows(md):
st, detail = check(ev)
if st == "ОЖИДАЕТ" and mode == "--final":
st = "ПРОВАЛ"
detail = "улики нет, а пак сдаётся"
mark = ""
if any(n in ev for n in selfnames):
selfref += 1
mark = " [сам-себя]"
bad += st == "ПРОВАЛ"
pend += st == "ОЖИДАЕТ"
print(f"[{st:8s}] {rid:6s}{mark} {req[:70]}" + (f"{detail}" if detail else ""))
total = len(rows(md))
print(f"\nтребований {total} · ожидают {pend} · провалов {bad}")
print(f"улик вида «греп своего же документа» ({'сам-себя'}): {selfref} из {total}"
+ (" ⚠ они доказывают, что НАПЕЧАТАНО, а не что СДЕЛАНО" if selfref else ""))
if bad:
print("СВЕРКА НЕ ПРОЙДЕНА")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())