242 lines
16 KiB
Python
242 lines
16 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 re
|
||
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, ""
|
||
|
||
|
||
REQ_ID = re.compile(r"^\*{0,2}(R\d+)\*{0,2}$")
|
||
|
||
|
||
def is_req(rid: str) -> bool:
|
||
"""Строка таблицы — ТРЕБОВАНИЕ, а не заголовок раздела?
|
||
|
||
⚠ ЗАЧЕМ ОТДЕЛЬНАЯ ФУНКЦИЯ, А НЕ УСЛОВИЕ НА МЕСТЕ. Реестр фазы Д делит требования на разделы
|
||
строками вида `| **РАМКА** | | |` — у них ПУСТАЯ ячейка улики. Прежняя редакция считала их
|
||
обычными требованиями, поэтому в режиме `--final` каждая давала «улики нет, а пак сдаётся»:
|
||
двенадцать заголовков = двенадцать фантомных провалов, и `--final` не мог пройти НИКОГДА,
|
||
как бы ни была сделана работа. Заодно врал итог: «требований 89» при 77 настоящих.
|
||
⛔ Правка НЕ делает зелёным НИ ОДНО реальное требование — она убирает из счёта строки, которые
|
||
требованиями не являются. Проверено гейтом ниже на заведомо больном входе.
|
||
"""
|
||
return bool(REQ_ID.match(rid.strip()))
|
||
|
||
|
||
def rows(md: str) -> list[tuple[str, str, str]]:
|
||
"""Строки таблицы: (ID, требование, улика). Заголовки разделов тоже здесь — фильтрует `is_req`."""
|
||
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
|
||
# ⚠ Markdown экранирует вертикальную черту внутри ячейки как `\|`. Наивный `split("|")`
|
||
# рвал такую ячейку пополам, и улика приходила обрезанной: у R55 в оболочку уезжало
|
||
# `... | wc -l) -ge 7` без начала, что давало «Syntax error» и ЛОЖНЫЙ КРАСНЫЙ, а у R37 —
|
||
# ЛОЖНЫЙ ЗЕЛЁНЫЙ, потому что обрезок случайно возвращал 0. Гейт, который сам портит
|
||
# улику, не проверяет ничего. Восстанавливается ОБЫЧНАЯ черта: в markdown `\|` и означает
|
||
# литеральный `|`, и в оболочку должен уйти именно он.
|
||
cells = [c.replace("\x00", "|").strip()
|
||
for c in s.strip("|").replace("\\|", "\x00").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 "--selftest" in args:
|
||
return selftest()
|
||
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):
|
||
if not is_req(rid):
|
||
print(f"\n── {rid.strip('*')} " + "─" * max(0, 60 - len(rid)))
|
||
continue
|
||
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 = sum(1 for rid, _r, _e in rows(md) if is_req(rid))
|
||
print(f"\nтребований {total} · ожидают {pend} · провалов {bad}")
|
||
print(f"улик вида «греп своего же документа» ({'сам-себя'}): {selfref} из {total}"
|
||
+ (" ⚠ они доказывают, что НАПЕЧАТАНО, а не что СДЕЛАНО" if selfref else ""))
|
||
if bad:
|
||
print("СВЕРКА НЕ ПРОЙДЕНА")
|
||
return 1 if bad else 0
|
||
|
||
|
||
def selftest() -> int:
|
||
"""ГЕЙТ ПРАВКИ «заголовок раздела — не требование». Роняется на заведомо больном входе.
|
||
|
||
⚠ Норма фазы: гейт, который не может упасть, ничего не сторожит. Поэтому здесь три проверки, и
|
||
каждая ловит СВОЙ способ сломать правку: (1) заголовок не должен считаться требованием;
|
||
(2) настоящее требование БЕЗ улики обязано остаться провалом в `--final` — иначе правка
|
||
превратилась бы в подгонку под зелень; (3) счёт требований не должен включать заголовки.
|
||
"""
|
||
bad = 0
|
||
|
||
def ck(ok: bool, what: str) -> None:
|
||
nonlocal bad
|
||
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {what}")
|
||
bad += 0 if ok else 1
|
||
|
||
fake = (MARK + "\n"
|
||
"| ID | требование | улика |\n"
|
||
"|---|---|---|\n"
|
||
"| **РАМКА** | | |\n"
|
||
"| R1 | требование с уликой | `$ true` |\n"
|
||
"| R2 | требование БЕЗ улики | — |\n"
|
||
"| **НОРМЫ** | | |\n")
|
||
rs = rows(fake)
|
||
ids = [r[0] for r in rs]
|
||
ck(len(rs) == 4, f"разобрано строк таблицы: {len(rs)} (ждём 4, вместе с заголовками)")
|
||
ck([r for r in ids if is_req(r)] == ["R1", "R2"],
|
||
f"требованиями считаются только R-строки: {[r for r in ids if is_req(r)]}")
|
||
ck(not is_req("**РАМКА**") and not is_req("**НОРМЫ РИГА**"),
|
||
"заголовок раздела требованием НЕ считается")
|
||
ck(is_req("R77") and is_req("**R64**"), "R-строка считается требованием и в жирном начертании")
|
||
ck(check("—")[0] == "ОЖИДАЕТ", "требование без улики — ОЖИДАЕТ (в --final станет ПРОВАЛом)")
|
||
ck(check("`$ false`")[0] == "ПРОВАЛ", "падающая улика — ПРОВАЛ")
|
||
ck(check("`$ true`")[0] == "OK", "проходящая улика — OK")
|
||
print("\nГЕЙТ СВЕРКИ ГОДЕН" if not bad else f"\n⛔ ПРОВАЛОВ {bad}")
|
||
return 1 if bad else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|