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