#!/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"^## (D(\d+)\.(\d+))") # эра не прибита к 39 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("|")] # ⚠ Колонки читаются С КОНЦА, а не с начала. Первая содержательная ячейка («Хвост»/«Суть») законно # содержит `|` внутри кода — например строка бэклога 127 несёт «seed|ruby|auto», и разбор слева # отдавал в качестве зоны слово «ruby». Из-за этого счёт зоны в CURRENT-STATE был 62/79 при истине # 63/80 (нашла панель ревью 08.08). Хвостовые колонки короткие и `|` в них не бывает. BACKLOG_SHAPE = 8 # '' | ID | хвост | зона | вес | чем закрывается | источник | '' REGISTER_SHAPE = 9 # '' | ID | класс | серьёзность | где | суть | статус | источник | '' def col(line: str, from_end: int) -> str: """Ячейка, считая с конца: 2 = последняя содержательная, 3 = предпоследняя и так далее.""" c = line.split("|") return c[-from_end].strip() def malformed(rows: list, shape: int, what: str) -> list: """Строка таблицы неожиданной формы — расхождение, а не тихий IndexError.""" bad = [] for l in rows: n = len(l.split("|")) if n < shape: bad.append(f"{what}: строка «{l.split('|')[1].strip()}» имеет {n - 2} колонок, ожидалось {shape - 2}") return bad 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 col(l, 5) == "бэкенд"), "бэкенд широко": sum(1 for l in rows if "бэкенд" in col(l, 5)), "скоро": sum(1 for l in rows if "скоро" in col(l, 4)), "блокеров очереди": sum(1 for l in rows if "блокер-очереди" in col(l, 4)), "дубли ID": [i for i, n in Counter(ids).items() if n > 1], "битая форма": malformed(rows, BACKLOG_SHAPE, "бэклог"), } 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: st, sev = col(l, 3), col(l, 6).replace("*", "") if st == "open": status["open"] += 1 open_ids.append(cells(l)[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, "битая форма": malformed(rows, REGISTER_SHAPE, "регистр"), } def head(dlog: str, prog: str) -> dict: found = [(int(m.group(2)), int(m.group(3)), m.group(1)) for l in dlog.splitlines() if (m := NOTE.match(l))] notes = [f[2] for f in found] # Максимум по НОМЕРУ: в файле уже есть внеочередные аппенды (## D39.17 → D39.19 → D39.18), и # «последняя по порядку» однажды перестанет быть головой (нашла панель ревью 08.08). top = max(found)[2] if found else None banner = re.search(r"D1–(D\d+\.\d+)", dlog.splitlines()[0]) if dlog else None claimed = None for l in prog.splitlines()[:8]: m = re.search(r"голова (D\d+\.\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 { "максимум по номеру": top, "последняя по порядку файла": last, "порядок = номер": last == top, "шапка D-лога": b, "голова в CURRENT-STATE": claimed, "сходится": bool(top) and top == 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", "скоро")), # ⚠ Литерал веса регистра СНЯТ с проверки 08.08, и это не упрощение, а исправление ошибки # проектирования: единственным его носителем в зоне docs была ИСТОРИЧЕСКАЯ фраза о том, что нашла # приёмка, а гард требовал от неё равенства ТЕКУЩЕМУ пере-счёту регистра. Первый же фикс зоны # платформы (закрыто 10 строк за 20 минут) превратил правдивую запись в «расхождение», и # предписанное лечение было бы «перепиши историю». Живой счёт живёт в зонном журнале платформы, # проверять его отсюда — значит винить зону docs за состояние чужого файла. Предсказано ревью. ] 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['максимум по номеру']}, " f"шапка D-лога {h['шапка D-лога']}, CURRENT-STATE {h['голова в CURRENT-STATE']}" ) if b["дубли ID"]: bad.append(f"дубли ID строк бэклога: {b['дубли ID']}") bad.extend(b["битая форма"]) bad.extend(r["битая форма"]) if not h["порядок = номер"]: bad.append( f"внеочередной аппенд в D-логе: последняя по порядку {h['последняя по порядку файла']}, " f"максимум по номеру {h['максимум по номеру']} — голова считается по максимуму" ) 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())