752 lines
55 KiB
Python
752 lines
55 KiB
Python
#!/usr/bin/env python3
|
||
"""Печатает производные числа доков, которые до сих пор переписывались руками.
|
||
|
||
Заведён после верификации записей №15 (D39.112 п.4): за одну сессию четыре из десяти рукописных
|
||
копий производных чисел разошлись с источником. Норма D39.101 требует считать очередь «только
|
||
скриптом» — скрипта при этом не существовало, и норма исполнялась глазами.
|
||
|
||
Числа, которые печатает этот файл, в доках писать литералами больше не надо: строка носителя несёт
|
||
команду. Кто всё же пишет литерал — обязан сверить его этой командой в том же касании.
|
||
|
||
python3 docs/scripts/counts.py печатает числа
|
||
python3 docs/scripts/counts.py --check сверяет литералы доков с пере-счётом; код 1 = расхождение
|
||
python3 docs/scripts/counts.py --check --from-index то же, но по СОДЕРЖИМОМУ КОММИТА
|
||
(индекс для застейдженного файла, HEAD для остальных)
|
||
python3 docs/scripts/counts.py --lint линтер формы (строка 167): file:line-якоря живых доков
|
||
(файл существует? строка в пределах файла?); код 1 = мёртвые якоря
|
||
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
|
||
|
||
|
||
# `\|` — ЗАКОННОЕ 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: гейт, проверенный один раз
|
||
руками, — это не пин, а замер, и он тихо умирает от первой же правки парсера.
|
||
|
||
Четыре утверждения, каждое куплено настоящим случаем 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 = [
|
||
(PROGRESS, "current-state", r"всего \*\*(\d+)\*\* строк", ("backlog", "всего")),
|
||
(PROGRESS, "current-state", r"зона бэкенд \*\*(\d+)\*\* строго", ("backlog", "бэкенд строго")),
|
||
(PROGRESS, "current-state", r"/ \*\*(\d+)\*\* широко", ("backlog", "бэкенд широко")),
|
||
(PROGRESS, "current-state", r"«скоро» \*\*(\d+)\*\*", ("backlog", "скоро")),
|
||
# ⚠ Добавлено 23.08 (оркестратор №19): пятое число ТОЙ ЖЕ фразы очереди скрипт считал и печатал,
|
||
# но НЕ сравнивал, и другого гарда на него в репозитории не было — «блокеров 0» могло разойтись
|
||
# с таблицей молча. Литерал не обёрнут своими `**` — жирным помечена вся фраза целиком.
|
||
(PROGRESS, "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")),
|
||
]
|
||
|
||
|
||
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}
|
||
targets = [ROOT / "CLAUDE.md"] + [
|
||
p for base in ("docs", "platform/docs", "frontend/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)
|
||
b, r, h = backlog(prog), 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}
|
||
for path, reg_name, pattern, (kind, key) in LITERALS:
|
||
if kind == "backlog":
|
||
want = b[key]
|
||
elif kind == "регистр-статус":
|
||
want = r["по статусу"].get(key, 0)
|
||
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())
|