textmachine/docs/scripts/counts.py

335 lines
19 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 для остальных)
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:
# Только секция «## Бэклог» — до следующего «## »: числовые ID-таблицы в ОТЧЁТАХ сессий
# (секция «Бэкенд» и др.) не строки трекера; впервые словлено приёмкой D39.131.
start = text.find("## Бэклог")
if start >= 0:
end = text.find("\n## ", start + 1)
text = text[start:end] if end > 0 else text[start:]
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())