гейт: добавлена проверка ссылок и номеров журнала решений
- `decisions.py` судит четыре вещи: уникальность номеров Т/Р/С, раскладку тем, указатель и ссылки — цель существует, подпись называет именно её; - проверка идёт без glob, как адреса: файл темы и ссылки на него лежат порознь, и переименование темы трогает только одну сторону, а ломает обе; - ссылка внутри блока кода ссылкой не считается — в скелетах канона она адресована дереву проекта.
This commit is contained in:
@@ -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), ставится один раз на клон:
|
конфиг в [lefthook.yml](lefthook.yml), ставится один раз на клон:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -539,6 +539,7 @@ lefthook run pre-commit # прогнать руками, не коммитя
|
|||||||
| фронтматтеры | правка `*.md` или `*.json` | весь репозиторий | миллисекунды |
|
| фронтматтеры | правка `*.md` или `*.json` | весь репозиторий | миллисекунды |
|
||||||
| копии правил | правка `*.md` | весь репозиторий | миллисекунды |
|
| копии правил | правка `*.md` | весь репозиторий | миллисекунды |
|
||||||
| адреса документов | **каждый коммит** | весь репозиторий | ~0.07 с |
|
| адреса документов | **каждый коммит** | весь репозиторий | ~0.07 с |
|
||||||
|
| журнал решений | **каждый коммит** | весь репозиторий | ~0.09 с |
|
||||||
| диаграммы | правка `*.md` | staged-файлы | ~1 с на файл |
|
| диаграммы | правка `*.md` | staged-файлы | ~1 с на файл |
|
||||||
| `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды |
|
| `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды |
|
||||||
| `pyrefly check` | правка `*.py` | staged-файлы | доли секунды |
|
| `pyrefly check` | правка `*.py` | staged-файлы | доли секунды |
|
||||||
@@ -554,7 +555,16 @@ Glob разводит две половины: коммит, трогающий
|
|||||||
обходит весь репозиторий за сотые доли секунды — экономить тут нечего;
|
обходит весь репозиторий за сотые доли секунды — экономить тут нечего;
|
||||||
`addresses.py` идёт **без glob вовсе**, потому что сводит две стороны: перечень
|
`addresses.py` идёт **без glob вовсе**, потому что сводит две стороны: перечень
|
||||||
адресов лежит в `*.py` владельца, а упоминания — в `*.md` соседей, и коммит с
|
адресов лежит в `*.py` владельца, а упоминания — в `*.md` соседей, и коммит с
|
||||||
переименованием документа трогает только первую.
|
переименованием документа трогает только первую; `decisions.py` — по той же
|
||||||
|
причине: файл темы и ссылки на него лежат порознь, и переименование темы трогает
|
||||||
|
только одну сторону.
|
||||||
|
|
||||||
|
**Что судит `decisions.py`.** Номер записи журнала обязан быть один: буквенные
|
||||||
|
метки решений выродились до пятибуквенных и разъехались молча — `АЕАКЛ` означала
|
||||||
|
и тему 53, и тему 65. И подпись ссылки обязана называть свою цель: в
|
||||||
|
`[тема 5](05-project-start-lifecycle.md)` номер записан дважды, словом и путём, —
|
||||||
|
дубль неизбежный (иначе ссылку не прочитать, не открыв) и потому сверяемый, как
|
||||||
|
всякая копия.
|
||||||
|
|
||||||
**`ruff` чинит безопасное сам, и починка доносится до этого же коммита**
|
**`ruff` чинит безопасное сам, и починка доносится до этого же коммита**
|
||||||
(`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в
|
(`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
| коды выхода | 0 1 2 3 4 | здесь, ниже |
|
| коды выхода | 0 1 2 3 4 | здесь, ниже |
|
||||||
|
|
||||||
Две оси стоят домом **здесь**, и обе по одной причине: владельца у них нет.
|
Две оси стоят домом **здесь**, и обе по одной причине: владельца у них нет.
|
||||||
Коды выхода делят семь скриптов и три скилла, режим прогона — конвейер, сценарий
|
Коды выхода делят восемь скриптов и три скилла, режим прогона — конвейер, сценарий
|
||||||
обслуживания и два устава.
|
обслуживания и два устава.
|
||||||
|
|
||||||
## Что на что влияет
|
## Что на что влияет
|
||||||
@@ -104,5 +104,5 @@
|
|||||||
Словарь был объявлен «общим» в одиннадцати местах, и каждое объявление
|
Словарь был объявлен «общим» в одиннадцати местах, и каждое объявление
|
||||||
перечисляло **свой** набор соседей: «тот же, что у `tasks.py`», «тот же, что у
|
перечисляло **свой** набор соседей: «тот же, что у `tasks.py`», «тот же, что у
|
||||||
`tasks.py`, `docs.py` и `copies.py`», «общий словарь скриптов av-dev». Ни одно из
|
`tasks.py`, `docs.py` и `copies.py`», «общий словарь скриптов av-dev». Ни одно из
|
||||||
них не было домом, все — списки по памяти. Отсюда дом здесь: у словаря семь
|
них не было домом, все — списки по памяти. Отсюда дом здесь: у словаря восемь
|
||||||
скриптов-потребителей и ни одного владельца.
|
скриптов-потребителей и ни одного владельца.
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ pre-commit:
|
|||||||
- name: адреса документов
|
- name: адреса документов
|
||||||
run: python3 scripts/addresses.py
|
run: python3 scripts/addresses.py
|
||||||
|
|
||||||
|
# Тоже без glob, и по той же причине, что у адресов: проверка сводит две
|
||||||
|
# стороны — файл темы и ссылку на него. Коммит, переименовавший тему или
|
||||||
|
# тронувший только `decisions/`, ломает ссылки в README и TODO, которых в
|
||||||
|
# индексе нет. Номера при этом сверяются на уникальность: буквенные метки
|
||||||
|
# решений уже разъезжались молча — `АЕАКЛ` была занята дважды.
|
||||||
|
- name: журнал решений
|
||||||
|
run: python3 scripts/decisions.py
|
||||||
|
|
||||||
# Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим
|
# Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим
|
||||||
# chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого
|
# chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого
|
||||||
# скрипта: репозиторий целиком — 3 секунды, один файл — одна.
|
# скрипта: репозиторий целиком — 3 секунды, один файл — одна.
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user