textmachine/docs/scripts/counts.py

235 lines
13 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"^## (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())