#!/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" # ⚠ Таблица бэклога и её бюллетень живут ОТДЕЛЬНЫМ файлом с 06.09 (D39.218); в PROGRESS на # прежнем месте стоит заголовок-указатель, поэтому `region()` по-прежнему режет шапку по нему. BACKLOG = "docs/BACKLOG.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 # `\|` — ЗАКОННОЕ markdown-экранирование: таблица рендерит его как черту внутри ячейки. Наивный # split() его не понимает и дробит строку лишний раз (поймано на PD-99 27.08). CELL_SPLIT = re.compile(r"(? list[str]: return [c.strip() for c in CELL_SPLIT.split(line)] # ⚠ Колонки читаются С КОНЦА, а не с начала. Первая содержательная ячейка («Хвост»/«Суть») законно # содержит `|` внутри кода — например строка бэклога 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 = CELL_SPLIT.split(line) return c[-from_end].strip() # ⚠ Словарь весов бэклога. Его НЕ БЫЛО: `WEIGHT_VOCAB` ниже — словарь РЕГИСТРА платформы # (major/minor/info), а вес бэклога не судился ничем, и девять рядов его молча потеряли # (ревизия 02.09, строка 249). Форма — ПРЕФИКС ячейки: см. комментарий в backlog(). BACKLOG_WEIGHTS = ("блокер-очереди", "скоро", "когда-нибудь") # Девять рядов потеряли вес ДО заведения этого гейта — замер 02.09. Держим их «дедовщиной» по той же # конвенции, что STATUS_GRANDFATHERED ниже: гейт рождается ЗЕЛЁНЫМ на известном долге и КРАСНЕЕТ на # десятом. Иначе он краснит всегда, и его учатся игнорировать. ⚠ Каждая строка здесь — ряд, который # гейт перестал проверять; долг числится строкой бэклога 249 и снимается вместе с ней. # У 96/97 прочерк ЗАКОНЕН — это надгробия строк, уехавших в зонный бэклог (D39.84). WEIGHT_GRANDFATHERED = {"5", "13б", "94", "96", "97", "149", "205", "221", "246"} def weight_of(line: str) -> str: """Вес ряда бэклога — с начала ячейки, без разметки-украшений.""" return col(line, 4).lstrip("*⚠ ") def malformed(rows: list, shape: int, what: str) -> list: """Строка таблицы неожиданной формы — расхождение, а не тихий IndexError.""" bad = [] for l in rows: n = len(CELL_SPLIT.split(l)) if n < shape: bad.append(f"{what}: строка «{cells(l)[1]}» имеет {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)), # ⚠ Вес считается ПРЕФИКСОМ ячейки, а не подстрокой (ревизия 02.09, строка бэклога 249). # Подстрока ловила слово в ПРОЗЕ: фраза «переведена из «скоро»» ре-добавляла ряд в счёт, и # число скакнуло 53 → 56 при одной новой строке. Префикс убивает обе ловушки разом — и эту, # и потерю самого слова веса при правке ячейки. "скоро": sum(1 for l in rows if weight_of(l).startswith("скоро")), "блокеров очереди": sum(1 for l in rows if weight_of(l).startswith("блокер-очереди")), "вес вне словаря": [BACKLOG_ROW.match(l).group(1) for l in rows if not weight_of(l).startswith(BACKLOG_WEIGHTS) and BACKLOG_ROW.match(l).group(1) not in WEIGHT_GRANDFATHERED], "дубли ID": [i for i, n in Counter(ids).items() if n > 1], "битая форма": malformed(rows, BACKLOG_SHAPE, "бэклог"), } # ⚠ Хвостовой словарь — вторая половина PD-398, построена 27.08 по эмпирике трёх случаев за двое # суток (правка PD-396 · первая редакция PD-398 · пере-формулировка PD-398 приёмкой). Счёт ячеек # ловить ИЗБЫТОК не может: `|` внутри первой содержательной ячейки легален и намеренно (см. col()). # Но смягчение «читаем с конца, значит лишняя черта безвредна» ОПРОВЕРГНУТО: когда черта попадает в # одну из трёх ПОСЛЕДНИХ колонок, статус или вес съезжает МОЛЧА — счёт печатает мусорный ключ или # «иное», и никто не смотрит. Поэтому судим не форму, а словарь хвоста. STATUS_VOCAB = ("open", "fixed", "accepted-risk", "closed") WEIGHT_VOCAB = ("major", "minor", "info", "blocker") # Доброкачественные исключения — статус прозой, заведены ДО словаря. Расширять только сознательно: # каждая новая строка здесь — это строка, которую гейт перестал проверять. STATUS_GRANDFATHERED = {"PD-59"} def tail_vocab(rows: list, what: str) -> list: """Статус и вес читаются С КОНЦА: лишний «|» в хвосте сдвигает их молча. Судим по словарю.""" bad = [] for l in rows: pid = cells(l)[1] st = col(l, 3).replace("*", "").strip().lower() if not st.startswith(STATUS_VOCAB) and pid not in STATUS_GRANDFATHERED: bad.append(f"{what}: строка «{pid}» — статус «{st[:40]}» вне словаря " f"(лишний «|» в одной из трёх последних колонок?)") sev = cells(l)[3].replace("*", "").strip().lower() if not any(k in sev for k in WEIGHT_VOCAB): bad.append(f"{what}: строка «{pid}» — вес «{sev[:40]}» вне словаря " f"(лишний «|» в одной из трёх последних колонок?)") return bad def selftest_tail_vocab() -> list: """Пин гейта хвостового словаря (PD-398). Гоняется на КАЖДОМ --check: гейт, проверенный один раз руками, — это не пин, а замер, и он тихо умирает от первой же правки парсера. Четыре утверждения, каждое куплено настоящим случаем 25–27.08: 1. чистая строка молчит; 2. сырая «|» в СТАТУСНОЙ ячейке краснит — три случая за двое суток проходили молча; 3. markdown-экранирование «\|» законно и НЕ считается лишней ячейкой (PD-99 была невиновна); 4. сырая «|» в СЕРЕДИНЕ не сдвигает ВЕС — он читается с начала (на PD-197 сдвигала). """ ok = "| PD-999 | bug | major | `a.go:1` | суть | open | источник |" bad_tail = "| PD-999 | bug | major | `a.go:1` | суть | open | сломано | источник |" escaped = "| PD-999 | bug | major | `a.go:1` | код `x\\|y` | open | источник |" mid_pipe = "| PD-999 | bug | major | `a.go:1` | регекс `(a|b)` | open | источник |" fail = [] if tail_vocab([ok], "пин"): fail.append("пин: чистая строка не должна краснить") if not tail_vocab([bad_tail], "пин"): fail.append("ПИН УПАЛ: «|» в статусной ячейке НЕ поймана — вернулась молчаливая потеря PD-398") if len(cells(escaped)) != len(cells(ok)): fail.append("ПИН УПАЛ: экранированная «\\|» посчитана лишней ячейкой — PD-99 снова «сломана»") # ⚠ Утверждение 4 судит НАСТОЯЩИЙ путь — счётчик register(), а не cells(): первая редакция пина # проверяла cells()[3] и МОЛЧАЛА на возврате веса к чтению с конца (поймано посадкой 27.08). counted = register(mid_pipe)["вес открытых"] if counted.get("major") != 1: fail.append(f"ПИН УПАЛ: «|» в середине сдвинула ВЕС — вернулся механизм PD-197 (счёт: {counted})") return fail 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), cells(l)[3].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, "регистр"), "хвост вне словаря": tail_vocab(rows, "регистр"), } def head(dlog: str, prog: str, idx: 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 # ⚠ ТРЕТИЙ носитель головы, добавлен 04.09 (оркестратор №22) после того, как разошёлся МОЛЧА: # реестр нот держал «D1–D39.190» при голове D39.193, а гейт печатал «сходится: True», потому что # сверял только шапку D-лога и CURRENT-STATE. Титул реестра сам объявляет себя носителем головы # («бампать при каждом аппенде»), полноту рядов скрипт уже сторожит — не хватало ровно диапазона. ib = re.search(r"D1–(D\d+\.\d+)", idx.splitlines()[0]) if idx else None i = ib.group(1) if ib else None return { "максимум по номеру": top, "последняя по порядку файла": last, "порядок = номер": last == top, "шапка D-лога": b, "титул реестра нот": i, "голова в CURRENT-STATE": claimed, "сходится": bool(top) and top == claimed == b == i, } # Литералы, которые доки обязаны держать в согласии с пере-счётом. Каждый ОБЯЗАН найтись: # ненайденный литерал = проверка перестала проверять, и это докладывается как расхождение. # region ограничивает поиск, чтобы историческая секция со старым числом не давала ложной тревоги. LITERALS = [ (BACKLOG, "current-state", r"всего \*\*(\d+)\*\* строк", ("backlog", "всего")), (BACKLOG, "current-state", r"зона бэкенд \*\*(\d+)\*\* строго", ("backlog", "бэкенд строго")), (BACKLOG, "current-state", r"/ \*\*(\d+)\*\* широко", ("backlog", "бэкенд широко")), (BACKLOG, "current-state", r"«скоро» \*\*(\d+)\*\*", ("backlog", "скоро")), # ⚠ Добавлено 23.08 (оркестратор №19): пятое число ТОЙ ЖЕ фразы очереди скрипт считал и печатал, # но НЕ сравнивал, и другого гарда на него в репозитории не было — «блокеров 0» могло разойтись # с таблицей молча. Литерал не обёрнут своими `**` — жирным помечена вся фраза целиком. (BACKLOG, "current-state", r"блокеров очереди (\d+)\*\*", ("backlog", "блокеров очереди")), # ⚠ Литерал веса регистра СНЯТ с проверки 08.08, и это не упрощение, а исправление ошибки # проектирования: единственным его носителем в зоне docs была ИСТОРИЧЕСКАЯ фраза о том, что нашла # приёмка, а гард требовал от неё равенства ТЕКУЩЕМУ пере-счёту регистра. Первый же фикс зоны # платформы (закрыто 10 строк за 20 минут) превратил правдивую запись в «расхождение», и # предписанное лечение было бы «перепиши историю». Живой счёт живёт в зонном журнале платформы, # проверять его отсюда — значит винить зону docs за состояние чужого файла. Предсказано ревью. # # ⚠ ВОЗВРАЩЁН 04.09 (оркестратор №22) — и возвращён НЕ вопреки разбору выше, а потому, что его # посылка больше не держится. Тот разбор про ИСТОРИЧЕСКУЮ фразу («что нашла приёмка»), которую # гард заставлял бы переписывать при каждом чужом фиксе. Сегодня носитель ДРУГОЙ: живая строка # CURRENT-STATE «трезвость по масштабу», которая утверждает ТЕКУЩЕЕ состояние и обязана ехать # вместе с ним. Цена дыры предъявлена: строка говорила «95 (major 3)» при пере-счёте 101/5, и # скрипт печатал верное число экраном выше ложного литерала, объявляя «литералы сходятся». # ⚠ Регистр меняет ЗОНА, но коммитит его оркестратор — значит правка числа ложится тем же актом, # что и правка регистра, и «переписывать историю» этот гард ни от кого не требует. (PROGRESS, "current-state", r"открытых рядов регистра платформы — (\d+)", ("регистр-статус", "open")), (PROGRESS, "current-state", r"открытых рядов регистра платформы — \d+ \(major (\d+)\)", ("регистр-вес", "major")), # ⚠ ИТОГ РЯДОВ ДОБАВЛЕН 06.09, И ДЫРА БЫЛА ИМЕННО ТОЙ ФОРМЫ, ПРОТИВ КОТОРОЙ СТОИТ ВЕСЬ ЭТОТ СПИСОК: # строка CURRENT-STATE говорила «всего рядов 453 (пере-счёт `counts.py --check`)» при РЕАЛЬНЫХ 457, и # `--check` печатал «литералы сходятся» — потому что итог не сверялся НИКОГДА, а проза утверждала, что # сверяется. Открытые и major сторожились, итог ехал рядом с ними и читался как такой же охраняемый. # Нашла внешняя ревизия, не гейт: у гейта не было на это вопроса. Цена такой дыры выше обычной ошибки — # число под ложным обещанием проверки доверенней непроверенного. (PROGRESS, "current-state", r"всего рядов (\d+)", ("регистр-итог", "")), ] 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) не резолвятся и не проверяются. # ⚠ Якорь = путь + номер, плюс ДВЕ опциональные части, обе появились по разбору 21.08 (строка 205): # хвост диапазона (`mining.go:80-82,243` — «токен в пределах диапазона», иначе гейт врёт на легитимной # форме) и ОПТ-ИН ТОКЕН ОЖИДАНИЯ `` `путь:123`=`подстрока` ``. Токен нужен потому, что сам по себе # `file:line` носителя ожидания НЕ несёт: сверять «содержимое» эвристикой по окружающей прозе значит # врать в обе стороны, а хук, который врёт, учат игнорировать (докстринг ниже). ANCHOR = re.compile( r"([~A-Za-z0-9_\-./]+\.(?:go|py|md|ya?ml|txt|puml|json|sql|js|ts)):(\d+)((?:[-,]\d+)*)" r"`?(?:=`([^`]+)`)?" ) def touched_lines() -> dict[str, set[int]]: """Путь → номера строк, ДОБАВЛЕННЫХ этим коммитом (сторона документа для дельта-сверки).""" global _TOUCHED if _TOUCHED is None: # ⚠ Префиксы форсируем: при пользовательском `diff.noprefix=true` разбор `+++ b/` молча # вернул бы пусто, и сторона документа умерла бы БЕЗ ЗВУКА — худший класс отказа гейта. args = ["git", "diff", "-U0", "--src-prefix=a/", "--dst-prefix=b/"] + ( ["--cached"] if FROM_INDEX else ["HEAD"]) out = subprocess.run(args, cwd=ROOT, capture_output=True, text=True).stdout acc: dict[str, set[int]] = {} rel = None for line in out.splitlines(): if line.startswith("+++ b/"): rel = line[6:] elif line.startswith("@@") and rel: m = re.search(r"\+(\d+)(?:,(\d+))?", line) if m: start, count = int(m.group(1)), int(m.group(2) or 1) acc.setdefault(rel, set()).update(range(start, start + count)) _TOUCHED = acc return _TOUCHED _TOUCHED: dict[str, set[int]] | None = None LINT_PREFIXES_RAW = [ "", "backend/", "backend/internal/", "backend/cmd/", "backend/configs/", "backend/internal/chunk/", "platform/", "platform/internal/", "platform/cmd/", "platform/docs/", "frontend/", "frontend/docs/", "frontend/src/", "docs/", "docs/architecture/", "eval/", ] # Обзоры ЧУЖОГО кода: их якоря указывают в сторонние репо (litellm/crush/openai-go…) и в нашем # дереве не резолвятся по построению — не линтим. # ⚠ ЗАМОРОЖЕНО литералом, а не выведено из `iterdir()` (разбор 21.08): рантаймовое множество меняется # ВМЕСТЕ с болезнью — переименуй каталог, и все его якоря в тот же коммит тихо станут «внешними» # ровно тогда, когда нужен крик. Плюс случайный untracked-каталог в корне расширял бы фильтр. REPO_TOPS = {"backend", "docs", "eval", "frontend", "platform"} # Первые сегменты ЗОННО-ОТНОСИТЕЛЬНЫХ якорей (`internal/pgstore/…`, `cmd/…`) — их в корпусе больше # пятнадцати, и без этого множества сломавшийся зонный якорь молча переклассифицировался бы во # «внешнюю ссылку» вместо «файла нет». # ⚠ ИСПРАВЛЕНО 23.08 (оркестратор №19): было `pre.split("/", 1)[0]` — ПЕРВЫЙ сегмент, из-за чего # ZONE_TOPS выходил побайтно равен REPO_TOPS, условие ниже становилось тавтологией, и зонно- # относительный якорь (`internal/…`, `cmd/…`), который НЕ резолвится, тихо числился «внешней # ссылкой» вместо крика «файла нет» — ровно та слепота, против которой написан комментарий выше. # Нужен ВТОРОЙ сегмент: из `backend/internal/` — `internal`, из `frontend/src/` — `src`. ZONE_TOPS = {p.strip("/").split("/")[1] for p in LINT_PREFIXES_RAW if len(p.strip("/").split("/")) >= 2} # Токен короче этого не различает цель — «сверено по содержимому» становится самообманом (02.09). TOKEN_MIN = 12 # Два якоря короче минимума ТРОГАТЬ НЕЛЬЗЯ, и это не долг, а норма: один лежит в ТЕЛЕ ноты D39.176 # (append-only, D23.3), второй — в теле эксперимента (улика, D23.3). Гейт обязан молчать на них, # иначе он вечно красный на местах, которые сам же запрещает править. # ⚠ Привязка по ЯКОРЮ, а не по номеру строки: первая редакция ссылалась на номера и сломалась в тот # же день от вставки эрраты выше по файлу. Гейт, который сам гниёт от правки соседней строки, — не гейт. TOKEN_MIN_EXEMPT = { ("docs/architecture/05-decisions-log.md", "backend/cmd/tmctl/invocation.go:145"), ("docs/experiments/23-editor-tier.md", "docs/archive/PROGRESS-2026-08-10-15.md:23"), } LINT_SKIP_DOCS = { "docs/research/21-llm-transport-survey.md", "docs/research/26-anthropic-guidance-digest.md", } # Ноты D-лога адресуются НОМЕРОМ, а не строкой: две подрезки живого журнала (D39.125, D39.139) сдвинули # нумерацию и увезли тела в слайсы, из-за чего 51 якорь из 55 стал указывать в ЧУЖУЮ ноту, а токенный # линт этого не видел — у таких якорей токена нет. Проверка структурная и токена не требует: владелец # целевой строки — ближайший сверху `## D<номер>`, и он обязан совпасть с номером, названным В ТОЙ ЖЕ # строке дока. ⚠ `docs/research/**` исключён СОЗНАТЕЛЬНО: тела отчётов — внешняя улика, D23.3 запрещает # править их задним числом, поэтому мёртвые якоря там закрыты пометкой в ревью-шапке, а не правкой. DLOG_NAMES = ("05-decisions-log.md", "05-decisions-D") DLOG_BASENAMES = {p.name: str(p.relative_to(ROOT)) for p in [ROOT / "docs/architecture/05-decisions-log.md", *sorted((ROOT / "docs/archive/architecture").glob("05-decisions-*.md"))] if p.is_file()} DNUM = re.compile(r"^## (D\d+(?:\.\d+)*)") def dlog_owner(body: list[str], lineno: int) -> str | None: """Номер ноты, чьё ТЕЛО содержит строку lineno (ближайший `## D<номер>` сверху).""" cur = None for i, l in enumerate(body[:lineno], 1): m = DNUM.match(l) if m: cur = m.group(1) return cur 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 norm(text: str) -> str: """Пробельная нормализация — и только она. ⚠ Разбор 21.08: схлопывание пробелов и переносов ДЕТЕРМИНИРОВАНО и симметрично, это не шаг к угадыванию. Без него фраза, разорванная переносом строки в цели, непроверяема в принципе (док сканируется построчно, токен не может нести `\n`), а каждая будущая пере-вёрстка абзаца цели ломала бы токен и кричала «цель уехала» на невиновного — ложная тревога, отложенная на чужой коммит. РАЗМЕТКУ (`**`, бэктики) НЕ трогаем: в Go-целях `*` семантичен, и она же держит давление «спиши из цели дословно». Первая редакция была литеральной и первой же поймала САМ ГЕЙТ на лжи: токен был истинным, цель не двигалась, а гейт напечатал «цель уехала». """ return " ".join(text.split()) def anchor_window(lineno: int, tail: str) -> list[tuple[int, int]]: """`80-82,243` → [(80,82),(243,243)]. ОБЪЕДИНЕНИЕ отрезков, а не min..max. ⚠ `min..max` давал окно в 164 строки на `mining.go:80-82,243`, и токен, случайно живущий где-то внутри, красил гейт зелёным (баг реализации, пойман разбором 21.08). """ out = [] for part in f"{lineno}{tail}".split(","): a, _, b = part.partition("-") if a.strip().isdigit(): out.append((int(a), int(b) if b.strip().isdigit() else int(a))) return out or [(lineno, lineno)] def lint_anchors() -> tuple[list[tuple[int, str]], dict[str, int]]: """Якорный линт: сверка содержимого по опт-ин токену + дельта-ТРЕБОВАНИЕ токена. ⚠ У дельты ДВЕ РАЗНЫЕ роли, и путать их нельзя (разбор 21.08, строка 205): · **ТРЕБОВАТЬ** токен можно только на строках, которые коммит трогает — иначе пре-существующий долг блокирует всех; корпус мигрирует по мере касания. · **ПРОВЕРЯТЬ** уже написанные токены дельта не должна ограничивать ВООБЩЕ: токен был истинным в момент записи, значит упавший токен — настоящая гниль по построению, проблемы «старого долга» у него нет. Прежняя редакция сверяла токены только внутри дельты, и это схлопывало «пропущенное окно» (коммит без хука, `--no-verify`, свежий клон без установленных хуков, проигнорированное warn-предупреждение) из «до следующего прогона» в «навсегда». Поэтому: токены сверяются по ВСЕМУ живому корпусу каждый прогон; дельта управляет только требованием, и требование живёт только под `--from-index` (в ручном прогоне «дельта» = всё незакоммиченное КЕМ УГОДНО, включая чужой WIP параллельных сессий). """ bad: list[tuple[int, str]] = [] # (ранг тяжести, текст) — ранг нужен для приоритета под капом tally = {"content": 0, "notoken": 0, "external": 0, "unjudged": 0, "files": 0} # ⚠ `backend/docs` добавлен 04.09 (оркестратор №22): корень отсутствовал с самого заведения линтера, # и доки зоны ДВИЖКА не проверял никто — при том что они несут токен-якоря и сами ссылаются на этот # гейт как на защиту. Цена дыры предъявлена внешней ревизией: эррата от 02.09 в # `D15.2-content-addressed-resume-spec.md` УЖЕ указывала мимо, и линтер не мог этого увидеть по # построению. Гейт, не покрывающий корень, доказывает состояние только тех корней, которые обходит. targets = [ROOT / "CLAUDE.md"] + [ p for base in ("docs", "platform/docs", "frontend/docs", "backend/docs") for p in (ROOT / base).rglob("*.md") if "archive" not in p.parts and str(p.relative_to(ROOT)) not in LINT_SKIP_DOCS ] touched = touched_lines() line_counts: dict[Path, int] = {} body_cache: dict[Path, list[str]] = {} for doc in sorted(targets): doc_rel = str(doc.relative_to(ROOT)) text = doc_text(doc_rel) if text is None: continue tally["files"] += 1 for n, line in enumerate(text.splitlines(), 1): for m in ANCHOR.finditer(line): rel, lineno, tail, token = m.group(1).lstrip("/"), int(m.group(2)), m.group(3) or "", m.group(4) shown = f"{rel}:{lineno}{tail}" where = f"{doc_rel}:{n}" # ⚠ ГОЛОЕ имя журнала решений разрешается ДО общего отсева безслэшевых форм: корпус # пишет его именно так (`05-decisions-log.md:390`), и без этой строки структурная # проверка ниже не исполнялась НИ РАЗУ — поймано посадкой мутации 24.08, первая # редакция правила была молчащим no-op. if "/" not in rel and rel in DLOG_BASENAMES: rel = DLOG_BASENAMES[rel] if "~" in rel or "/" not in rel or "..." in rel: tally["unjudged"] += 1 continue # ран-локальные пути, голые имена и «…»-сокращения hit = next((ROOT / pre / rel for pre in LINT_PREFIXES_RAW if (ROOT / pre / rel).is_file()), None) if hit is None: # ⚠ Внешняя ссылка (stdlib Go, чужой проект) — судить нечем. Но проверка обязана # быть УЗКОЙ: зонно-относительные пути (`internal/…`, `cmd/…`) — НАШИ, и сломавшийся # такой якорь должен кричать «файла нет», а не тихо стать «внешним». top = rel.split("/", 1)[0] if top not in REPO_TOPS and top not in ZONE_TOPS: tally["external"] += 1 continue bad.append((1, f"{where}: якорь `{shown}` — файла нет ни под одним из префиксов")) continue if hit not in line_counts: body_cache[hit] = hit.read_text(encoding="utf-8", errors="replace").splitlines() line_counts[hit] = len(body_cache[hit]) if lineno > line_counts[hit]: bad.append((1, f"{where}: якорь `{shown}` — в {hit.relative_to(ROOT)} всего {line_counts[hit]} строк")) continue if any(k in rel for k in DLOG_NAMES) and not doc_rel.startswith("docs/research/"): owns = dlog_owner(body_cache[hit], lineno) named = set(re.findall(r"\bD(\d+(?:\.\d+)*)", line)) if owns and named and owns[1:] not in named: bad.append((0, f"{where}: якорь `{shown}` лежит в теле {owns}, а рядом названы " f"D{'/D'.join(sorted(named))} — адресуй ноту НОМЕРОМ, строка D-лога не переживает подрезку")) continue if (token is not None and len(token.strip()) < TOKEN_MIN and (doc_rel, rel + ":" + str(lineno)) not in TOKEN_MIN_EXEMPT): # ⚠ Токен короче TOKEN_MIN не доказывает НИЧЕГО: «`x`» найдётся в любой строке, # и якорь считался бы «сверенным по содержимому», не будучи им. Дыра найдена # при заведении храповика 02.09: доля сверяемых накручивается пустышкой. tally["notoken"] += 1 bad.append((2, f"{where}: токен якоря `{shown}` короче {TOKEN_MIN} знаков — " f"он не различает цель; возьми подстроку подлиннее")) continue if token is not None: body = body_cache[hit] window = " ".join( "\n".join(body[a - 1:min(b, len(body))]) for a, b in anchor_window(lineno, tail) ) if norm(token) in norm(window): tally["content"] += 1 else: bad.append((0, f"{where}: якорь `{shown}` — ожидаемого «{token}» в цели НЕТ (цель уехала)")) elif FROM_INDEX and n in touched.get(doc_rel, ()): tally["notoken"] += 1 bad.append((2, f"{where}: якорь `{shown}` на ЗАТРОНУТОЙ строке без токена ожидания — " f"допишите `` =`подстрока` ``, иначе содержимое не сверяемо")) else: tally["notoken"] += 1 return bad, tally 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, tally = lint_anchors() # ⚠ Кап с ПРИОРИТЕТОМ: залп из «допишите токен» на переразметке дока прятал бы за капом # настоящие «цель уехала». Ранг: 0 — цель уехала · 1 — файла нет / номер за концом · 2 — нет токена. problems.sort(key=lambda x: x[0]) CAP = 25 for _, msg in problems[:CAP]: print(" ✗ " + msg) if len(problems) > CAP: print(f" … ещё {len(problems) - CAP} не показано (кап вывода {CAP}, сначала тяжёлые)") # ⚠ РАЗБИВКА, КОТОРАЯ НЕ КАПИТСЯ — и она здесь не для красоты. Зона считает «сколько красных # целят в меня» грепом по строкам «✗», а их печатается не больше CAP, поэтому хвост уезжает # под черту МОЛЧА и грep возвращает ноль там, где хитов восемь. Так и случилось 04.09: # платформенная сессия доложила «в моей зоне красных нет», проверив обрезанный вывод. # Строка ниже считает ВСЕ проблемы по КОРНЮ ЦЕЛИ якоря, независимо от капа. by_target = {} for _, msg in problems: m = re.search(r"якор[ья] `([^`:]+)", msg) # «якорь …» и «токен якоря …» — обе формы root = m.group(1).split("/")[0] if m else "?" by_target[root] = by_target.get(root, 0) + 1 if by_target: parts = " · ".join(f"{k} {v}" for k, v in sorted(by_target.items(), key=lambda kv: -kv[1])) print(f" по КОРНЮ ЦЕЛИ (не капится, считай зону отсюда, а не грепом по ✗): {parts}") print(f"\n--lint: {len(problems)} проблемных якорей в {tally['files']} живых доках") # ⚠ Норма «гейт печатает, чего он НЕ проверяет» (D39.153 п.9г). Прежняя редакция молча # проверяла только существование файла и переполнение номера — и дала ЗЕЛЁНЫЙ на четыре # якоря, сломанных сдвигом содержимого. Ложная уверенность от гейта дороже его дыры. print( f" сверено ПО СОДЕРЖИМОМУ (токен): {tally['content']} · без токена — несверяемы: " f"{tally['notoken']} · внешних ссылок пропущено: {tally['external']} · " f"неразбираемых форм пропущено: {tally['unjudged']}" ) print(" Якоря в ЖУРНАЛ РЕШЕНИЙ (живой файл и слайсы) сверяются СТРУКТУРНО и без токена:") print(" владелец целевой строки — ближайший `## D<номер>` — обязан совпасть с номером в той же") print(" строке дока; `docs/research/**` из этого исключён намеренно (тела отчётов — улика, D23.3).") print(" НЕ проверяется: якорь без токена (только существование файла и длина) · подмена цели") print(" файлом с тем же путём · смысловая актуальность текста · archive/** и LINT_SKIP_DOCS.") print(" ⚠ Цели читаются из ДЕРЕВА (истина кода — дерево), поэтому чужой незакоммиченный WIP") print(" в цели может транзиентно и уронить токен, и спасти его.") return 1 if problems else 0 dlog, prog, reg = read(DLOG), read(PROGRESS), read(REGISTER) bck = read(BACKLOG) b, r, h = backlog(bck), register(reg), head(dlog, prog, read(NOTE_INDEX)) 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-лога']}, титул реестра {h['титул реестра нот']}, " f"CURRENT-STATE {h['голова в CURRENT-STATE']}" ) if b["дубли ID"]: bad.append(f"дубли ID строк бэклога: {b['дубли ID']}") bad.extend(b["битая форма"]) if b["вес вне словаря"]: bad.append(f"вес бэклога вне словаря {BACKLOG_WEIGHTS} у строк: {b['вес вне словаря']} — " f"вес обязан быть ПРЕФИКСОМ ячейки (строка бэклога 249)") bad.extend(r["битая форма"]) bad.extend(r["хвост вне словаря"]) bad.extend(selftest_tail_vocab()) if not h["порядок = номер"]: bad.append( f"внеочередной аппенд в D-логе: последняя по порядку {h['последняя по порядку файла']}, " f"максимум по номеру {h['максимум по номеру']} — голова считается по максимуму" ) texts = {PROGRESS: prog, REGISTER: reg, BACKLOG: bck} for path, reg_name, pattern, (kind, key) in LITERALS: if kind == "backlog": want = b[key] elif kind == "регистр-статус": want = r["по статусу"].get(key, 0) elif kind == "регистр-итог": want = r["всего строк"] else: want = 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())