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