#!/usr/bin/env python3 """Печатает производные числа доков, которые до сих пор переписывались руками. Заведён после верификации записей №15 (D39.112 п.4): за одну сессию четыре из десяти рукописных копий производных чисел разошлись с источником. Норма D39.101 требует считать очередь «только скриптом» — скрипта при этом не существовало, и норма исполнялась глазами. Числа, которые печатает этот файл, в доках писать литералами больше не надо: строка носителя несёт команду. Кто всё же пишет литерал — обязан сверить его этой командой в том же касании. python3 docs/scripts/counts.py печатает числа python3 docs/scripts/counts.py --check сверяет литералы доков с пере-счётом; код 1 = расхождение python3 docs/scripts/counts.py --check --from-index то же, но по СОДЕРЖИМОМУ КОММИТА (индекс для застейдженного файла, HEAD для остальных) ⚠ Про `--from-index`: без него скрипт читает рабочее дерево, и при ЧАСТИЧНОМ стейдже (техника «застейджить только свою правку в общем файле») он честно ругается на то, что в коммит не поедет. Хук зовёт именно `--from-index`, чтобы не давать ложных тревог — хук, который врёт, учат игнорировать. ⚠ Дисциплина самого скрипта: если ОЖИДАЕМЫЙ литерал в доке не найден, это РАСХОЖДЕНИЕ, а не тишина. Проверка, которая молча перестала проверять из-за переформулировки, хуже отсутствующей. """ from __future__ import annotations import re import subprocess import sys from collections import Counter from pathlib import Path ROOT = Path(__file__).resolve().parents[2] PROGRESS = "docs/PROGRESS.md" DLOG = "docs/architecture/05-decisions-log.md" REGISTER = "platform/docs/DEFECT_REGISTER.md" BACKLOG_ROW = re.compile(r"^\| (\d+[а-яё]?) \|") REGISTER_ROW = re.compile(r"^\| (PD-\d+) \|") NOTE = re.compile(r"^## (D39\.\d+)") FROM_INDEX = "--from-index" in sys.argv def read(rel: str) -> str: """Содержимое файла: из рабочего дерева либо из того, что поедет в коммит.""" if not FROM_INDEX: return (ROOT / rel).read_text(encoding="utf-8") staged = subprocess.run( ["git", "diff", "--cached", "--name-only"], cwd=ROOT, capture_output=True, text=True ).stdout.split() ref = f":{rel}" if rel in staged else f"HEAD:{rel}" got = subprocess.run(["git", "show", ref], cwd=ROOT, capture_output=True, text=True) if got.returncode != 0: # Новый файл вне индекса или отсутствующий в HEAD — читаем дерево, но говорим об этом. print(f" (⚠ {rel}: {ref} недоступен, читаю рабочее дерево)", file=sys.stderr) return (ROOT / rel).read_text(encoding="utf-8") return got.stdout def cells(line: str) -> list[str]: return [c.strip() for c in line.split("|")] def backlog(text: str) -> dict: rows = [l for l in text.splitlines() if BACKLOG_ROW.match(l)] ids = [BACKLOG_ROW.match(l).group(1) for l in rows] return { "всего": len(rows), "бэкенд строго": sum(1 for l in rows if cells(l)[3] == "бэкенд"), "бэкенд широко": sum(1 for l in rows if "бэкенд" in cells(l)[3]), "скоро": sum(1 for l in rows if "скоро" in cells(l)[4]), "блокеров очереди": sum(1 for l in rows if "блокер-очереди" in cells(l)[4]), "дубли ID": [i for i, n in Counter(ids).items() if n > 1], } def register(text: str) -> dict: rows = [l for l in text.splitlines() if REGISTER_ROW.match(l)] weight, status, open_ids = Counter(), Counter(), [] for l in rows: c = cells(l) st, sev = c[6], c[3].replace("*", "") if st == "open": status["open"] += 1 open_ids.append(c[1]) weight["major" if "major" in sev else "minor" if "minor" in sev else "info" if "info" in sev else sev] += 1 elif st.startswith("fixed"): status["fixed"] += 1 elif st.startswith("accepted-risk"): status["accepted-risk"] += 1 else: status["иное"] += 1 return { "всего строк": len(rows), "по статусу": dict(status), "вес открытых": dict(weight), "открытые": open_ids, } def head(dlog: str, prog: str) -> dict: notes = [NOTE.match(l).group(1) for l in dlog.splitlines() if NOTE.match(l)] banner = re.search(r"D1–(D39\.\d+)", dlog.splitlines()[0]) if dlog else None claimed = None for l in prog.splitlines()[:8]: m = re.search(r"голова (D39\.\d+)", l) if m: claimed = m.group(1) break last = notes[-1] if notes else None b = banner.group(1) if banner else None return { "последняя нота D-лога": last, "шапка D-лога": b, "голова в CURRENT-STATE": claimed, "сходится": bool(last) and last == claimed == b, } # Литералы, которые доки обязаны держать в согласии с пере-счётом. Каждый ОБЯЗАН найтись: # ненайденный литерал = проверка перестала проверять, и это докладывается как расхождение. # region ограничивает поиск, чтобы историческая секция со старым числом не давала ложной тревоги. LITERALS = [ (PROGRESS, "current-state", r"всего \*\*(\d+)\*\* строк", ("backlog", "всего")), (PROGRESS, "current-state", r"зона бэкенд \*\*(\d+)\*\* строго", ("backlog", "бэкенд строго")), (PROGRESS, "current-state", r"/ \*\*(\d+)\*\* широко", ("backlog", "бэкенд широко")), (PROGRESS, "current-state", r"«скоро» \*\*(\d+)\*\*", ("backlog", "скоро")), (PROGRESS, "current-state", r"\*\*(\d+) major\*\*", ("register-weight", "major")), ] def region(text: str, name: str) -> str: """current-state = шапка PROGRESS до таблицы бэклога; там и живут сводные числа.""" if name != "current-state": return text cut = text.find("## Бэклог") return text[: cut if cut > 0 else len(text)] def main() -> int: dlog, prog, reg = read(DLOG), read(PROGRESS), read(REGISTER) b, r, h = backlog(prog), register(reg), head(dlog, prog) check = "--check" in sys.argv src = "содержимое коммита (индекс/HEAD)" if FROM_INDEX else "рабочее дерево" print(f"ИСТОЧНИК: {src}\n") print("ГОЛОВА") for k, v in h.items(): print(f" {k}: {v}") print("\nБЭКЛОГ") for k, v in b.items(): print(f" {k}: {v}") print("\nРЕГИСТР ПЛАТФОРМЫ") for k, v in r.items(): print(f" {k}: {v if k != 'открытые' else ' '.join(v)}") if not check: return 0 bad = [] if not h["сходится"]: bad.append( f"голова разошлась: последняя нота {h['последняя нота D-лога']}, " f"шапка D-лога {h['шапка D-лога']}, CURRENT-STATE {h['голова в CURRENT-STATE']}" ) if b["дубли ID"]: bad.append(f"дубли ID строк бэклога: {b['дубли ID']}") texts = {PROGRESS: prog, REGISTER: reg} for path, reg_name, pattern, (kind, key) in LITERALS: want = b[key] if kind == "backlog" else r["вес открытых"].get(key, 0) found = re.search(pattern, region(texts[path], reg_name)) if not found: bad.append( f"{path}: литерал по шаблону /{pattern}/ НЕ НАЙДЕН — либо формулировку сменили, " f"либо число выпало; проверка этого числа перестала работать (ожидалось {want})" ) elif int(found.group(1)) != want: bad.append(f"{path}: «{found.group(0)}» против пере-счёта {want}") if bad: print("\nРАСХОЖДЕНИЯ:") for line in bad: print(" ✗ " + line) return 1 print(f"\nЛитералы сходятся с пере-счётом ({len(LITERALS)} проверок).") return 0 if __name__ == "__main__": sys.exit(main())