textmachine/docs/scripts/counts.py

196 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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", "скоро")),
# ⚠ Литерал веса регистра СНЯТ с проверки 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['последняя нота 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())