#!/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 = мёртвые якоря python3 docs/scripts/counts.py --lint --from-index то же, но сканируемые доки читаются ПО СОДЕРЖИМОМУ КОММИТА (цели якорей — всегда из дерева) --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-index`) и ТОЛЬКО ПРЕДУПРЕЖДАЕТ, выходя нулём — ⚠ прежняя редакция этой строки говорила «--lint запускается руками», это снято ревизией D39.148 (довод: ручной прогон жил только в промте оркестратора, а ловил реальные протухшие якоря); препятствие, которое хочется обойти, — повод для пинга, не для обхода. """ 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 _STAGED: list[str] | None = None def staged_files() -> list[str]: """Состав коммита — ОДНИМ вызовом на процесс. Кэш здесь не микро-оптимизация: линт обходит ~90 доков, и вызов на каждый давал бы под две сотни сабпроцессов на коммит (поймано ревью при вплетении --lint в хук, D39.148). ⚠ При ЧАСТИЧНОМ коммите (`git commit -- <пути>`) git строит временный индекс и отдаёт его хуку через GIT_INDEX_FILE, поэтому `git diff --cached` ВНУТРИ хука показывает именно состав коммита, а не то, что лежало в настоящем индексе. Тот же GIT_INDEX_FILE наследуется этим процессом, так что и `git show :файл` ниже читает ту же временную картину. Прогон скрипта РУКАМИ, вне коммита, видит настоящий индекс — это другая среда, и на ней выводы о поведении хука не строятся. """ global _STAGED if _STAGED is None: _STAGED = subprocess.run( ["git", "diff", "--cached", "--name-only"], cwd=ROOT, capture_output=True, text=True ).stdout.split() return _STAGED _WT: dict[str, str] | None = None def worktree_state() -> dict[str, str]: """Путь → 'modified' | 'untracked' для файлов, чьё дерево может расходиться с HEAD. Считается ОДИН раз на процесс. Чего в словаре НЕТ — то чисто, и его дерево побайтно равно HEAD; такой файл читается с диска без сабпроцесса. ⚠ Двумя plumbing-командами, по одному пути на строку, а НЕ `git status --porcelain`: у того на переименовании запись несёт ПАРУ путей, и наивный разбор принял бы второй за отдельную строку с мусорным кодом состояния. """ global _WT if _WT is None: def lines(*args: str) -> list[str]: return subprocess.run( ["git", *args], cwd=ROOT, capture_output=True, text=True ).stdout.splitlines() _WT = {p: "modified" for p in lines("diff", "--name-only", "--no-renames", "HEAD")} for p in lines("ls-files", "--others", "--exclude-standard"): _WT[p] = "untracked" return _WT def read(rel: str) -> str: """Содержимое файла: из рабочего дерева либо из того, что поедет в коммит.""" if not FROM_INDEX: return (ROOT / rel).read_text(encoding="utf-8") ref = f":{rel}" if rel in staged_files() 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 doc_text(rel: str) -> str | None: """Текст СКАНИРУЕМОГО ДОКА для линта. Под `--from-index` — то, что поедет в коммит (индекс для застейдженного, HEAD для прочих): иначе чужой незакоммиченный WIP давал бы ложные тревоги — в `docs/experiments/` постоянно живёт рабочее дерево полигона, а хук, который врёт, учат игнорировать (D39.148). `None` = файла нет ни в коммите, ни в HEAD (untracked чужой) — такой док не судим вовсе. ⚠ ЦЕЛИ якорей (`backend/*.go` и т.п.) читаются НЕ здесь, а из рабочего дерева, и это осознанно: код не входит в docs-коммит, и его актуальная истина — дерево, а не HEAD. """ if not FROM_INDEX: return (ROOT / rel).read_text(encoding="utf-8") if rel in staged_files(): ref = f":{rel}" else: state = worktree_state().get(rel) if state is None: # Чистый отслеживаемый файл: дерево побайтно равно HEAD, читаем с диска БЕЗ сабпроцесса. # На 87 сканируемых доках это разница между 87 вызовами `git show` и единицами. # ⚠ Файла может не быть вовсе (снесён в дереве; в списке целей есть жёсткая позиция # CLAUDE.md) — тогда судить нечего, а НЕ падать: гейт, падающий вместо вердикта, даёт # предупреждение с пустым телом (поймано тест-коммитом при вплетении, D39.148). path = ROOT / rel return path.read_text(encoding="utf-8") if path.is_file() else None if state == "untracked": return None # чужой untracked док — не в коммите и не в HEAD, не судим ref = f"HEAD:{rel}" got = subprocess.run(["git", "show", ref], cwd=ROOT, capture_output=True, text=True) return got.stdout if got.returncode == 0 else None 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): text = doc_text(str(doc.relative_to(ROOT))) if text is None: continue for n, line in enumerate(text.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())