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