196 lines
10 KiB
Python
196 lines
10 KiB
Python
#!/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())
|