Files
dev-skills/scripts/decisions.py
T
av eae734f5cc гейт: добавлена проверка ссылок и номеров журнала решений
- `decisions.py` судит четыре вещи: уникальность номеров Т/Р/С, раскладку тем,
  указатель и ссылки — цель существует, подпись называет именно её;
- проверка идёт без glob, как адреса: файл темы и ссылки на него лежат порознь,
  и переименование темы трогает только одну сторону, а ломает обе;
- ссылка внутри блока кода ссылкой не считается — в скелетах канона она
  адресована дереву проекта.
2026-08-13 12:41:13 +03:00

273 lines
14 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
"""Целостность журнала решений: номера уникальны, ссылки ведут туда, куда обещают.
Журнал разложен по теме на файл, и обе его связки держатся вниманием, которого
хватает ненадолго.
**Номера.** До сквозной нумерации решения метились буквами — `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)