#!/usr/bin/env python3 """Целостность журнала решений: номера уникальны, ссылки ведут туда, куда обещают. Журнал разложен по теме на файл, и обе его связки держатся вниманием, которого хватает ненадолго. **Номера.** До сквозной нумерации решения метились буквами — `A`…`Z`, потом `AA`…`ZZZ`, потом кириллицей, потом четвёрками и пятёрками букв. Схема выродилась и сломалась молча: метки `АЕАКЛ`, `АЕАКМ` и `АЕАКН` оказались заняты дважды — темами 53–55 и темой 65, — а ссылка на такую метку означает две разные записи и не разрешается ни во что. Номер обязан быть один; здесь это и проверяется. **Ссылки.** Тема ссылается на тему файлом, и подпись ссылки дублирует цель: `[тема 5](05-project-start-lifecycle.md)` называет номер дважды — словом и путём. Дубль неизбежен (иначе ссылку нельзя прочитать, не открыв), поэтому он **сверяется**, как всякая копия: подпись против имени файла, `РN`/`СN` — против файла, где эта запись и объявлена. Переименование темы или перенос решения ловит эта же сверка. Проверяются ссылки **внутри** журнала и ссылки на журнал **снаружи** — из README, TODO и прочего: переименование файла темы ломает их одинаково, а трогает такой коммит только одну сторону. Ссылка внутри блока кода ссылкой не считается: там она либо пример разметки, либо адресована дереву чужого проекта, а не этому репозиторию. Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф против окружения» — av-dev/shared/axes.md. Значения — в константах ниже. """ from __future__ import annotations import itertools import re import sys from pathlib import Path OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4 JOURNAL = "decisions" INDEX = "README.md" SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", "tmp"} # Имя файла темы: номер, дефис, слаг. Номер в имени — то, по чему тема # адресуется, поэтому он же сверяется с заголовком и с подписями ссылок. TOPIC_FILE = re.compile(r"^(\d{2})-([a-z0-9-]+)\.md$") TOPIC_HEAD = re.compile(r"^# (\d+)\. (.+) \((\d{4}-\d{2}-\d{2})\)$") # Объявление записи: жирный абзац, открывающийся меткой. `Т` живёт в индексе, # `Р` и `С` — в темах. RECORD = re.compile(r"^\*\*([ТРС])(\d+)\.", re.M) LINK = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)") CODE_SPAN = re.compile(r"`[^`]*`") FENCE = re.compile(r"^\s*(```|~~~)") # Подпись, называющая запись: «тема 5», «теме 5», «Р6», «С109». Склонение # свободное — сверяется число, а не слово. SIGN_NUM = re.compile(r"\b(?:тем[аеыуой]{1,2}|Тем[аеыуой]{1,2})\s+(\d+)") SIGN_RECORD = re.compile(r"^([ТРС])(\d+)$") # Строка индекса: `| 5 | [Заголовок](05-slug.md) | 2026-08-03 |`. INDEX_ROW = re.compile(r"^\|\s*(\d+)\s*\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|" r"\s*(\d{4}-\d{2}-\d{2})\s*\|\s*$", re.M) def prose(text: str) -> str: """Текст без блоков и вставок кода: там ссылка — пример, а не ссылка.""" out, fenced = [], False for line in text.split("\n"): if FENCE.match(line): fenced = not fenced continue out.append("" if fenced else CODE_SPAN.sub("`код`", line)) return "\n".join(out) def links(text: str) -> list[tuple[int, str, str]]: """Ссылки прозы: номер строки, подпись, цель. Разбор идёт по всему тексту, а не построчно: подпись законно разрывается переносом (`[тема\n5](…)`), и построчный разбор молча пропустил бы каждую десятую ссылку журнала — ровно те, что длиннее полстроки. """ body = prose(text) out = [] for m in LINK.finditer(body): target = m.group(2) if target.startswith(("http://", "https://", "mailto:")): continue line = body[:m.start()].count("\n") + 1 out.append((line, " ".join(m.group(1).split()), target)) return out def walk(root: Path) -> list[Path]: out = [] for p in sorted(root.rglob("*.md")): if SKIP_DIRS & set(p.relative_to(root).parts): continue out.append(p) return out def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() if not (root / ".claude-plugin").is_dir(): print(f"ОТКАЗ: {root} не похож на корень маркетплейса: нет .claude-plugin/", file=sys.stderr) return ENV journal = root / JOURNAL if not (journal / INDEX).is_file(): print(f"ОТКАЗ: журнала нет: {JOURNAL}/{INDEX} не читается", file=sys.stderr) return ENV findings: list[str] = [] # --- раскладка: имя файла, заголовок и номер темы говорят одно и то же --- topics: dict[int, Path] = {} for p in sorted(journal.glob("*.md")): if p.name == INDEX: continue m = TOPIC_FILE.match(p.name) if not m: findings.append(f"{JOURNAL}/{p.name}: имя не вида `NN-слаг.md` —" f" по номеру в имени тема и адресуется") continue num = int(m.group(1)) if num in topics: findings.append(f"{JOURNAL}/{p.name}: номер {num} уже занят" f" файлом {topics[num].name}") continue topics[num] = p head = p.read_text(encoding="utf-8").split("\n", 1)[0] h = TOPIC_HEAD.match(head) if not h: findings.append(f"{JOURNAL}/{p.name}:1: заголовок не вида" f" `# N. Тема (ГГГГ-ММ-ДД)`") elif int(h.group(1)) != num: findings.append(f"{JOURNAL}/{p.name}:1: заголовок называет тему" f" {h.group(1)}, а имя файла — {num}") missing = [n for n in range(1, max(topics, default=0) + 1) if n not in topics] if missing: findings.append(f"{JOURNAL}/: тем не хватает: {missing} — номер темы" f" закреплён навсегда, дыра означает потерянный файл") # --- номера записей: одна метка — одна запись, без дыр и вразбивку --- where: dict[tuple[str, int], str] = {} order: dict[str, list[int]] = {"Т": [], "Р": [], "С": []} for p in [journal / INDEX] + [topics[n] for n in sorted(topics)]: text = p.read_text(encoding="utf-8") for m in RECORD.finditer(prose(text)): kind, num = m.group(1), int(m.group(2)) line = prose(text)[:m.start()].count("\n") + 1 here = f"{JOURNAL}/{p.name}:{line}" if (kind, num) in where: findings.append(f"{here}: {kind}{num} уже объявлено" f" в {where[(kind, num)]} — метка обязана быть одна") continue where[(kind, num)] = here order[kind].append(num) for kind, nums in order.items(): if not nums: continue holes = [n for n in range(1, max(nums) + 1) if n not in set(nums)] if holes: findings.append(f"{JOURNAL}/: {kind}-номера с дырами: {holes} —" f" номер закреплён за записью навсегда") if nums != sorted(nums): back = [(a, b) for a, b in itertools.pairwise(nums) if b < a] findings.append(f"{JOURNAL}/: {kind}-номера идут вразбивку" f" ({back[0][0]} → {back[0][1]}) — счёт сквозной" f" по порядку журнала") # --- индекс: каждая тема названа ровно раз и ровно та --- index_text = (journal / INDEX).read_text(encoding="utf-8") listed: dict[int, str] = {} for m in INDEX_ROW.finditer(index_text): num, title, target = int(m.group(1)), m.group(2), m.group(3) if num in listed: findings.append(f"{JOURNAL}/{INDEX}: тема {num} в указателе дважды") continue listed[num] = target if num not in topics: findings.append(f"{JOURNAL}/{INDEX}: тема {num} названа," f" а файла с таким номером нет") continue if target != topics[num].name: findings.append(f"{JOURNAL}/{INDEX}: тема {num} ведёт в {target}," f" а лежит в {topics[num].name}") head = TOPIC_HEAD.match(topics[num].read_text(encoding="utf-8").split("\n")[0]) if head and head.group(2) != title: findings.append(f"{JOURNAL}/{INDEX}: подпись темы {num} разошлась" f" с её заголовком:\n указатель: {title}" f"\n тема: {head.group(2)}") for num in sorted(set(topics) - set(listed)): findings.append(f"{JOURNAL}/{INDEX}: тема {num}" f" ({topics[num].name}) в указателе не названа") # --- ссылки: цель существует, а подпись называет именно её --- seen = 0 for path in walk(root): rel = path.relative_to(root).as_posix() inside = rel.startswith(f"{JOURNAL}/") for num, label, target in links(path.read_text(encoding="utf-8")): anchor = "" if "#" in target: target, anchor = target.split("#", 1) dest = (path.parent / target).resolve() if target else path in_journal = JOURNAL in dest.relative_to(root).parts if ( dest.is_relative_to(root)) else False if not (inside or in_journal): continue # чужая ссылка мимо журнала — не наш предмет seen += 1 if not dest.exists(): findings.append(f"{rel}:{num}: `{target}` не существует") continue if not in_journal or dest.name == INDEX: continue m = TOPIC_FILE.match(dest.name) if not m: continue said = SIGN_NUM.search(label) if said and int(said.group(1)) != int(m.group(1)): findings.append(f"{rel}:{num}: подпись называет тему" f" {said.group(1)}, а ведёт в {dest.name}") continue rec = SIGN_RECORD.match(label.strip()) if rec: kind, number = rec.group(1), int(rec.group(2)) home = where.get((kind, number)) if home is None: findings.append(f"{rel}:{num}: {kind}{number} нигде" f" не объявлено") elif not home.startswith(f"{JOURNAL}/{dest.name}:"): findings.append(f"{rel}:{num}: {kind}{number} ведёт" f" в {dest.name}, а объявлено в {home}") if anchor: heads = {h.lower() for h in re.findall( r"^#+ (.+)$", dest.read_text(encoding="utf-8"), re.M)} slugs = {re.sub(r"[^\w\s-]", "", h).strip().replace(" ", "-") for h in heads} if anchor.lower() not in slugs: findings.append(f"{rel}:{num}: якоря `#{anchor}`" f" в {dest.name} нет") print(f"тем {len(topics)}, записей Т{len(order['Т'])} Р{len(order['Р'])}" f" С{len(order['С'])}, ссылок журнала {seen}") if findings: print() for f in findings: print(f"РАСХОЖДЕНИЕ {f}") print(f"\nИтог: расхождений {len(findings)}. Номер записи задним числом" f" не переназначается: правится ссылка, а не то, на что она ведёт.") return DRIFT print("номера уникальны, ссылки журнала ведут туда, куда обещают") return OK if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(INTERNAL) except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr) sys.exit(INTERNAL)