textmachine/docs/scripts/counts.py

770 lines
57 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 = мёртвые якоря
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"(?<!\\)\|")
def cells(line: str) -> 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: гейт, проверенный один раз
руками, — это не пин, а замер, и он тихо умирает от первой же правки парсера.
Четыре утверждения, каждое куплено настоящим случаем 2527.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) после того, как разошёлся МОЛЧА:
# реестр нот держал «D1D39.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())