329 lines
19 KiB
Python
329 lines
19 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 для остальных)
|
||
python3 docs/scripts/counts.py --lint линтер формы (строка 167): file:line-якоря живых доков
|
||
(файл существует? строка в пределах файла?); код 1 = мёртвые якоря
|
||
|
||
--check также сторожит ПОЛНОТУ реестра нот (05-decisions-index.md): каждый заголовок `## D…`/`### D…`
|
||
живого D-лога и слайсов обязан иметь строку реестра, и наоборот (новая нота = новая строка ТЕМ ЖЕ коммитом).
|
||
|
||
⚠ Про `--from-index`: без него скрипт читает рабочее дерево, и при ЧАСТИЧНОМ стейдже (техника
|
||
«застейджить только свою правку в общем файле») он честно ругается на то, что в коммит не поедет.
|
||
Хук зовёт именно `--from-index`, чтобы не давать ложных тревог — хук, который врёт, учат игнорировать.
|
||
|
||
⚠ Дисциплина самого скрипта: если ОЖИДАЕМЫЙ литерал в доке не найден, это РАСХОЖДЕНИЕ, а не тишина.
|
||
Проверка, которая молча перестала проверять из-за переформулировки, хуже отсутствующей.
|
||
|
||
⚠ ЧУЖИМ СЕССИЯМ (бэкенд/полигон/фронт/платформа): скрипт — зона ОРКЕСТРАТОРА (docs/), написан им
|
||
(--check/голова — D39.112; полнота реестра нот и --lint якорей — D39.126, строка 167). Красный
|
||
--check или --lint НЕ обходить и не «чинить» подгонкой доков/реестра под зелень — это тот же запрет,
|
||
что подгонка тестов (D39.121/CLAUDE.md). Непонятно, мешает или кажется неправым → пинг оркестратору
|
||
через владельца. Блокировать он и не может: pre-commit хук зовёт только --check и ПРЕДУПРЕЖДАЕТ,
|
||
--lint запускается руками; препятствие, которое хочется обойти, — повод для пинга, не для обхода.
|
||
"""
|
||
|
||
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"
|
||
NOTE_INDEX = "docs/architecture/05-decisions-index.md"
|
||
|
||
|
||
def dlog_slices() -> list[str]:
|
||
"""Архив-слайсы D-лога (docs/archive/architecture/05-decisions-*.md), rel-путями."""
|
||
return sorted(
|
||
str(p.relative_to(ROOT))
|
||
for p in (ROOT / "docs/archive/architecture").glob("05-decisions-*.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 за состояние чужого файла. Предсказано ревью.
|
||
]
|
||
|
||
|
||
NOTE_HEADING = re.compile(r"^#{2,3} (D\d\S*)")
|
||
INDEX_ROW = re.compile(r"^\| (D\d\S*) \|")
|
||
|
||
|
||
def note_token(tok: str) -> str:
|
||
return tok.rstrip(".,:")
|
||
|
||
|
||
def index_completeness() -> list[str]:
|
||
"""Каждый заголовок ноты (живой D-лог + слайсы) ↔ строка реестра 05-decisions-index.md."""
|
||
headings: set[str] = set()
|
||
for rel in [DLOG, *dlog_slices()]:
|
||
for line in read(rel).splitlines():
|
||
if m := NOTE_HEADING.match(line):
|
||
headings.add(note_token(m.group(1)))
|
||
rows = {note_token(m.group(1)) for l in read(NOTE_INDEX).splitlines() if (m := INDEX_ROW.match(l))}
|
||
bad = []
|
||
if missing := sorted(headings - rows):
|
||
bad.append(f"реестр нот: НЕТ строк для {missing} — новая нота обязана получить строку тем же коммитом")
|
||
if orphan := sorted(rows - headings):
|
||
bad.append(f"реестр нот: строки без заголовка в файлах: {orphan}")
|
||
return bad
|
||
|
||
|
||
# --lint: file:line-якоря живых доков. Проверяется СУЩЕСТВОВАНИЕ файла и что строка ≤ длины файла —
|
||
# это ловит переименованные/удалённые файлы и грубое протухание; содержание строки механически
|
||
# не проверить. Голые имена без «/» (miner.go:123) не резолвятся и не проверяются.
|
||
ANCHOR = re.compile(r"([~A-Za-z0-9_\-./]+\.(?:go|py|md|ya?ml|txt|puml|json|sql|js|ts)):(\d+)")
|
||
LINT_PREFIXES = [
|
||
"", "backend/", "backend/internal/", "backend/cmd/", "backend/configs/", "backend/internal/chunk/",
|
||
"platform/", "platform/internal/", "platform/cmd/", "platform/docs/",
|
||
"frontend/", "frontend/docs/", "docs/", "docs/architecture/", "eval/",
|
||
]
|
||
# Обзоры ЧУЖОГО кода: их якоря указывают в сторонние репо (litellm/crush/openai-go…) и в нашем
|
||
# дереве не резолвятся по построению — не линтим.
|
||
LINT_SKIP_DOCS = {
|
||
"docs/research/21-llm-transport-survey.md",
|
||
"docs/research/26-anthropic-guidance-digest.md",
|
||
}
|
||
|
||
|
||
def lint_anchors() -> list[str]:
|
||
bad = []
|
||
targets = [ROOT / "CLAUDE.md"] + [
|
||
p for p in (ROOT / "docs").rglob("*.md")
|
||
if "archive" not in p.parts and str(p.relative_to(ROOT)) not in LINT_SKIP_DOCS
|
||
]
|
||
line_counts: dict[Path, int] = {}
|
||
for doc in sorted(targets):
|
||
for n, line in enumerate(doc.read_text(encoding="utf-8").splitlines(), 1):
|
||
for m in ANCHOR.finditer(line):
|
||
rel, lineno = m.group(1).lstrip("/"), int(m.group(2))
|
||
if "~" in rel or "/" not in rel or "..." in rel:
|
||
continue # ран-локальные пути, голые имена и «…»-сокращения не судим
|
||
hit = next((ROOT / pre / rel for pre in LINT_PREFIXES if (ROOT / pre / rel).is_file()), None)
|
||
where = f"{doc.relative_to(ROOT)}:{n}"
|
||
if hit is None:
|
||
bad.append(f"{where}: якорь `{m.group(0)}` — файла нет ни под одним из префиксов {LINT_PREFIXES}")
|
||
continue
|
||
if hit not in line_counts:
|
||
line_counts[hit] = len(hit.read_text(encoding="utf-8", errors="replace").splitlines())
|
||
if lineno > line_counts[hit]:
|
||
bad.append(f"{where}: якорь `{m.group(0)}` — в {hit.relative_to(ROOT)} всего {line_counts[hit]} строк")
|
||
return bad
|
||
|
||
|
||
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:
|
||
if "--lint" in sys.argv:
|
||
problems = lint_anchors()
|
||
for p in problems:
|
||
print(" ✗ " + p)
|
||
print(f"\n--lint: {len(problems)} мёртвых/переросших file:line-якорей в живых доках")
|
||
return 1 if problems else 0
|
||
|
||
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 = index_completeness()
|
||
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())
|