diff --git a/README.md b/README.md index 4cb9fb9..7694f22 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ python3 scripts/diagrams.py A.md B.md # только названные фай ## Гейт коммита -Все шесть проверок стоят в `pre-commit` через [lefthook](https://lefthook.dev) — +Все семь проверок стоят в `pre-commit` через [lefthook](https://lefthook.dev) — конфиг в [lefthook.yml](lefthook.yml), ставится один раз на клон: ``` @@ -539,6 +539,7 @@ lefthook run pre-commit # прогнать руками, не коммитя | фронтматтеры | правка `*.md` или `*.json` | весь репозиторий | миллисекунды | | копии правил | правка `*.md` | весь репозиторий | миллисекунды | | адреса документов | **каждый коммит** | весь репозиторий | ~0.07 с | +| журнал решений | **каждый коммит** | весь репозиторий | ~0.09 с | | диаграммы | правка `*.md` | staged-файлы | ~1 с на файл | | `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды | | `pyrefly check` | правка `*.py` | staged-файлы | доли секунды | @@ -554,7 +555,16 @@ Glob разводит две половины: коммит, трогающий обходит весь репозиторий за сотые доли секунды — экономить тут нечего; `addresses.py` идёт **без glob вовсе**, потому что сводит две стороны: перечень адресов лежит в `*.py` владельца, а упоминания — в `*.md` соседей, и коммит с -переименованием документа трогает только первую. +переименованием документа трогает только первую; `decisions.py` — по той же +причине: файл темы и ссылки на него лежат порознь, и переименование темы трогает +только одну сторону. + +**Что судит `decisions.py`.** Номер записи журнала обязан быть один: буквенные +метки решений выродились до пятибуквенных и разъехались молча — `АЕАКЛ` означала +и тему 53, и тему 65. И подпись ссылки обязана называть свою цель: в +`[тема 5](05-project-start-lifecycle.md)` номер записан дважды, словом и путём, — +дубль неизбежный (иначе ссылку не прочитать, не открыв) и потому сверяемый, как +всякая копия. **`ruff` чинит безопасное сам, и починка доносится до этого же коммита** (`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в diff --git a/av-dev/shared/axes.md b/av-dev/shared/axes.md index d42c92a..5c8cbe8 100644 --- a/av-dev/shared/axes.md +++ b/av-dev/shared/axes.md @@ -26,7 +26,7 @@ | коды выхода | 0 1 2 3 4 | здесь, ниже | Две оси стоят домом **здесь**, и обе по одной причине: владельца у них нет. -Коды выхода делят семь скриптов и три скилла, режим прогона — конвейер, сценарий +Коды выхода делят восемь скриптов и три скилла, режим прогона — конвейер, сценарий обслуживания и два устава. ## Что на что влияет @@ -104,5 +104,5 @@ Словарь был объявлен «общим» в одиннадцати местах, и каждое объявление перечисляло **свой** набор соседей: «тот же, что у `tasks.py`», «тот же, что у `tasks.py`, `docs.py` и `copies.py`», «общий словарь скриптов av-dev». Ни одно из -них не было домом, все — списки по памяти. Отсюда дом здесь: у словаря семь +них не было домом, все — списки по памяти. Отсюда дом здесь: у словаря восемь скриптов-потребителей и ни одного владельца. diff --git a/lefthook.yml b/lefthook.yml index 26547ad..0a6ec15 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -44,6 +44,14 @@ pre-commit: - name: адреса документов run: python3 scripts/addresses.py + # Тоже без glob, и по той же причине, что у адресов: проверка сводит две + # стороны — файл темы и ссылку на него. Коммит, переименовавший тему или + # тронувший только `decisions/`, ломает ссылки в README и TODO, которых в + # индексе нет. Номера при этом сверяются на уникальность: буквенные метки + # решений уже разъезжались молча — `АЕАКЛ` была занята дважды. + - name: журнал решений + run: python3 scripts/decisions.py + # Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим # chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого # скрипта: репозиторий целиком — 3 секунды, один файл — одна. diff --git a/scripts/decisions.py b/scripts/decisions.py new file mode 100644 index 0000000..d38fc70 --- /dev/null +++ b/scripts/decisions.py @@ -0,0 +1,272 @@ +#!/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)