Files
dev-skills/av-dev-pm/skills/canon/scripts/docs.py
T
avandClaude Opus 5 4386eb3e1c шов между плагинами: канон перестал называть имена проходов
av-dev-pm и av-dev-pipeline раздельны: канон работает без конвейера, конвейер без
канона — поразрядно деградируя и называя это строкой. Но канон в шести местах
называл конвейер поимённо, и одно из них — вывод docs.py пользователю: «свои темы
проекта: … — их разбирает review-basics». Такая строка чинится не правкой файла,
а недоумением на чужом проекте.

Правило записано в canon.md, чтобы не отрастало заново. Общий словарь — имена тем
и имена ступеней, и только они: ими проект настраивает ревью, вопросами по темам
и триггерами профиля. Имён проходов канон не называет нигде. Направление
несимметрично, и это верно: конвейер называет документы канона поимённо, потому
что он их читатель, а обратной ссылки быть не может — документ живёт дольше, чем
раскладка проходов.

Что вычищено: имя review-basics в canon.md, в changelog версии 5 и в выводе
docs.py; «её берёт basics» из таблицы ролей; описательные адресации того же
класса — «архитектурный проход судит», «враждебный проход выдумает». Худшей была
строка в skeletons.md «там идут враждебный, эксплуатационный и архитектурный
проходы»: утверждение о составе ступени, живущее на стороне, которая о составе не
знает. Строка таблицы «эксплуатационный проход ревью» стала «тема ревью
operations» — заодно совпала со словарём, к которому tasks/SKILL.md отсылает как
к единому дому.

Отдельно — пример, нарушавший собственное правило. Объяснение, почему вопросы
адресуются темам, звучало «вопрос, адресованный ops, перестал задаваться в тот
день, когда ops уехал в верхнюю ступень»: правило про нестабильность имён,
проиллюстрированное именем. Стало «адресованный проходу» и переживёт
переименование.

Починена и висячая ссылка: project-facts.md отсылал к таблице «Кто читает» в
каноне, которой там нет — она была убрана правкой, вводившей темы, и по новому
правилу её и не должно быть. Списка читателей не ведёт никто: читателя назначает
план прогона.

Тема 38 в DECISIONS.md, следствия 143-144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 08:57:09 +03:00

641 lines
30 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
"""Проверка раскладки документов проекта против канона av-dev.
Определение канона — references/canon.md рядом со скриптом. Здесь только
механизируемая часть: пути, лишние файлы, битые ссылки, версия, плейсхолдеры,
маркеры долга и две сверки с кодом. Смысловые дубли и оставшееся в архитектуре
поведение судит агент — скрипт об этом говорит вслух в конце отчёта.
Коды выхода — тот же словарь, что у tasks.py:
0 сошлось
1 дрейф раскладки (рабочая ситуация, чинится)
2 ошибка употребления
3 окружение: не тот каталог, битый конфиг
4 внутренний сбой
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
CANON_VERSION = 5
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# --- Раскладка канона -------------------------------------------------------
# Тема канона: имя → на какой вопрос отвечает (для внятного отказа).
#
# **Тема живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с README.md
# внутри.** Форму выбирает проект: тема разрослась — стала каталогом, и это не
# смена канона и не повод править скрипт. Обе формы сразу — ошибка: это два дома
# для одного факта, ровно то, от чего канон и защищает.
THEMES = {
"passport": "зачем и для кого, чем НЕ является",
"architecture": "как сложено — обзор, окружение, эксплуатация",
"security": "периметр, недоверенный вход, что вне модели",
"conventions": "как мы пишем код; индекс, промоут, что механизировано",
"research": "что показала реальность: наблюдения и числа с провенансом",
"adr": "почему решено так; индекс, статусы, правило замены",
"review": "настройка конвейера + журнал дефектов",
}
# Тема, обязательная только при условии: имя → (ключ .pm.json, пояснение).
CONDITIONAL_THEMES = {
"database": ("migrations", "схема хранилища и настройки"),
}
# Обязательные файлы вне тем.
REQUIRED = {
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
"docs/.pm.json": "версия канона и пути, нужные проверкам",
}
# Файлы, которые тема-каталог обязана держать сверх README.md.
THEME_EXTRA = {
"adr": {"template.md": "шаблон записи ADR"},
}
# Служебное в docs/ и каталог, который ведёт tasks.py.
NOT_THEMES = {".pm.json", "tasks"}
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое. Имена,
# совпадающие с темой, отсюда убраны намеренно: `docs/conventions.md` и
# `docs/review/` теперь законные формы своих тем.
RETIRED = {
"review-brief.md": "документы канона и есть бриф; остаток — в review",
"review-journal.md": "→ тема review",
"plan.md": "→ docs/tasks/ROADMAP.md",
"local-research.md": "→ тема research",
"specs": "поведение → openspec/specs/, обзор → тема architecture",
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
"backlog": "→ docs/tasks/",
}
# --- Слаги в именах файлов --------------------------------------------------
# Текст документов русский, а **имена файлов английские, kebab-case**. Причина
# не в эстетике: имя файла стоит в ссылках из других документов, в коммитах и в
# путях, которые люди набирают руками, — а кириллица в пути ломается по-разному
# в разных местах и не набирается на английской раскладке.
SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
ADR_NAME = re.compile(r"ADR-(\d{4})-(\d{2})-(\d{2})-(.+)")
CYRILLIC = re.compile(r"[а-яёА-ЯЁ]")
# Признаки транслита — и только они. Отличить английское слово от транслита
# машина не умеет, поэтому находка идёт **замечанием**: кластеры, которых в
# английском практически не бывает, плюс окончания русских падежей.
#
# Слабые маркеры выброшены намеренно, каждый по своему ложному срабатыванию:
# `ost` ловит `post` и `cost`, `sch` — `schema`, `ya` — `yaml`, `nost` —
# `nostalgia`, хвост `ii` — `radii`. Набор подобран так, чтобы ложных
# срабатываний не было вовсе: правило, краснеющее на правде, приучает
# пролистывать весь блок. Цена известна и принята — `sostoyanie-partii`
# проходит мимо.
#
# Тот же приём, что `translit_ish` в tasks.py; скрипты независимы намеренно —
# каждый уезжает в чужой проект в одиночку.
TRANSLIT_CLUSTER = re.compile(r"zh|kh|shch|tsy|iya|ovanie|enie|stvo")
TRANSLIT_TAIL = re.compile(r"(?:ej|oj|ij|yj|yy|aya)$")
def translit_ish(slug: str) -> bool:
if TRANSLIT_CLUSTER.search(slug):
return True
return any(TRANSLIT_TAIL.search(part) for part in slug.split("-"))
def check_slugs(root: Path, rep: Report) -> None:
"""Имена файлов канона: латиница kebab-case, у ADR — ещё и форма имени.
Каталог задач не трогаем: его слаги ведёт и проверяет tasks.py, и вторая
проверка того же места разошлась бы с первой.
"""
docs = root / "docs"
if not docs.is_dir():
return
# Имена, выбранные каноном, а не проектом: их форма задана здесь же.
fixed = {"README.md", "template.md"} | {f"{name}.md" for name in THEMES}
# Все темы-каталоги, включая свои темы проекта: правило имён общее, а
# перечислять их поимённо значило бы закрыть открытый список.
for folder in sorted(docs.iterdir()):
if not folder.is_dir() or folder.name in NOT_THEMES:
continue
sub = folder.name
for path in sorted(folder.rglob("*.md")):
name = path.name
rel = path.relative_to(root)
if name in fixed:
continue
stem = path.stem
if sub == "adr":
m = ADR_NAME.fullmatch(stem)
if not m:
rep.error(
f"{rel}: имя не по форме ADR-ГГГГ-ММ-ДД-slug.md — "
f"по имени сортируются записи и ищется дата решения"
)
continue
stem = m.group(4)
if CYRILLIC.search(stem):
rep.error(
f"{rel}: кириллица в имени файла — слаги английские, "
f"kebab-case (текст документа при этом русский)"
)
continue
if not SLUG.fullmatch(stem):
rep.error(
f"{rel}: имя не kebab-case латиницей — только строчные "
f"буквы, цифры и одиночные дефисы"
)
continue
if translit_ish(stem):
rep.note(
f"{rel}: имя похоже на транслит («{stem}») — слаг именуется "
f"английским словом по сути, а не записью русского латиницей: "
f"транслит нечитаем тому, кто ищет по смыслу. Проверено "
f"эвристикой: английское слово от транслита машина не отличает"
)
check_capability_slugs(root, rep)
def check_capability_slugs(root: Path, rep: Report) -> None:
specs = root / "openspec" / "specs"
if not specs.is_dir():
return
for folder in sorted(specs.iterdir()):
if not folder.is_dir():
continue
if CYRILLIC.search(folder.name) or not SLUG.fullmatch(folder.name):
rep.error(
f"openspec/specs/{folder.name}/: имя capability — латиница "
f"kebab-case; оно стоит в ссылках из architecture.md и в спеках"
)
DEBT_MARKER = re.compile(r"<!--\s*канон:\s*(.+?)\s*-->")
PLACEHOLDER = re.compile(r"<!--\s*заполнить:\s*(.+?)\s*-->")
MD_LINK = re.compile(r"\[[^\]]*\]\(\s*<?([^)>\s]+)>?(?:\s+[\"'(][^)]*)?\)")
FENCE = re.compile(r"^\s*(```|~~~)")
INLINE_CODE = re.compile(r"`[^`\n]*`")
def strip_code(text: str) -> str:
"""Выкинуть блоки кода и вставки в обратных кавычках.
Путь в примере или в шаблоне — не ссылка, и краснеть на нём значит краснеть
на каждом образце документа. Инлайн-код тоже: `[docs/backlog](docs/tasks/…)`
в тексте про подписи ссылок — иллюстрация, а не ссылка."""
out, inside = [], False
for line in text.splitlines():
if FENCE.match(line):
inside = not inside
continue
out.append("" if inside else INLINE_CODE.sub("", line))
return "\n".join(out)
@dataclass
class Report:
errors: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
debts: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
def error(self, msg: str) -> None:
self.errors.append(msg)
def note(self, msg: str) -> None:
self.notes.append(msg)
def debt(self, msg: str) -> None:
self.debts.append(msg)
def skip(self, msg: str) -> None:
self.skipped.append(msg)
def fail(code: int, msg: str) -> NoReturn:
print(f"ОТКАЗ: {msg}", file=sys.stderr)
sys.exit(code)
def read_config(root: Path, rep: Report) -> dict:
path = root / "docs" / ".pm.json"
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(ENV, f"docs/.pm.json не разбирается: {exc}")
if not isinstance(data, dict):
fail(ENV, "docs/.pm.json должен быть объектом")
return data
# --- Проверки ---------------------------------------------------------------
def check_version(root: Path, cfg: dict, rep: Report) -> None:
if not (root / "docs" / ".pm.json").exists():
return # об отсутствии файла скажет check_required, второй раз не нужно
if "canon" not in cfg:
rep.error("в docs/.pm.json нет ключа canon — версия канона не объявлена")
return
got = cfg["canon"]
if not isinstance(got, int):
rep.error(f"canon в docs/.pm.json должен быть целым числом, а не {got!r}")
return
if got < CANON_VERSION:
rep.error(
f"проект приведён к канону версии {got}, текущая — {CANON_VERSION}: "
f"нужен canon upgrade"
)
elif got > CANON_VERSION:
rep.error(
f"проект приведён к канону версии {got}, а скрипт знает {CANON_VERSION}: "
f"устарел плагин, обнови маркетплейс"
)
def theme_home(root: Path, name: str) -> tuple[Path | None, str | None]:
"""Дом темы: файл `docs/<имя>.md` или каталог `docs/<имя>/`.
Возвращает путь и жалобу. Обе формы сразу — это два дома для одного факта, и
расходятся они молча: правят одну, читают другую.
"""
docs = root / "docs"
as_file = docs / f"{name}.md"
as_dir = docs / name
if as_file.is_file() and as_dir.is_dir():
return as_file, (
f"тема {name} живёт сразу двумя домами — docs/{name}.md и docs/{name}/:"
f" оставить один, иначе правят один, а читают другой"
)
if as_file.is_file():
return as_file, None
if as_dir.is_dir():
if not (as_dir / "README.md").is_file():
return as_dir, (
f"docs/{name}/ без README.md — у темы-каталога вход обязателен:"
f" по нему её читают агенты"
)
return as_dir, None
return None, None
def check_required(root: Path, cfg: dict, rep: Report) -> None:
for rel, what in REQUIRED.items():
if not (root / rel).exists():
rep.error(f"нет {rel}{what}")
for name, what in THEMES.items():
home, complaint = theme_home(root, name)
if home is None:
rep.error(f"нет темы {name} (docs/{name}.md или docs/{name}/) — {what}")
continue
if complaint:
rep.error(complaint)
if home.is_dir():
for extra, why in THEME_EXTRA.get(name, {}).items():
if not (home / extra).is_file():
rep.error(f"нет docs/{name}/{extra}{why}")
for name, (key, what) in CONDITIONAL_THEMES.items():
home, complaint = theme_home(root, name)
if complaint:
rep.error(complaint)
if key in cfg and home is None:
rep.error(
f"нет темы {name} (docs/{name}.md или docs/{name}/) — {what}"
f" (обязательна: в .pm.json объявлен {key})"
)
elif key not in cfg and home is None:
rep.skip(f"тема {name} — в .pm.json нет ключа {key}, проверка неприменима")
def check_stray(root: Path, rep: Report) -> None:
"""Лишнего в docs/ больше нет — есть темы проекта.
Список тем **открытый**: каждый документ в docs/ и есть заявка на тему
ревью, и запретить проекту завести свою нельзя. Проверяются только слоты,
у которых дом в другом месте, — иначе переехавшее содержимое вернулось бы
темой и выглядело законным.
"""
docs = root / "docs"
if not docs.is_dir():
rep.error("нет каталога docs/")
return
known = set(THEMES) | set(CONDITIONAL_THEMES)
own: list[str] = []
for entry in sorted(docs.iterdir()):
name = entry.name
if name in RETIRED:
rep.error(f"docs/{name} — слота нет в каноне: {RETIRED[name]}")
continue
if name in NOT_THEMES:
continue
theme = name[:-3] if entry.is_file() and name.endswith(".md") else name
if theme in known:
continue
if entry.is_file() and not name.endswith(".md"):
rep.error(f"docs/{name} — не markdown: тема ревью читается как текст")
continue
if entry.is_dir() and not (entry / "README.md").is_file():
rep.error(
f"docs/{name}/ без README.md — у темы-каталога вход обязателен:"
f" по нему её читают агенты"
)
continue
own.append(theme)
if own:
rep.note(
f"свои темы проекта: {', '.join(own)} — именной оптики у них нет,"
f" их разбирает общий проход конвейера"
)
def canon_docs(root: Path) -> list[Path]:
"""Документы канона. Каталог задач ведёт tasks.py; упразднённые каталоги
уже названы отдельной строкой, и их внутренние ссылки не наша забота —
они переезжают целиком."""
out = []
docs = root / "docs"
skip = {"tasks"} | {name for name in RETIRED if not name.endswith(".md")}
if docs.is_dir():
for path in sorted(docs.rglob("*.md")):
head = path.relative_to(docs).parts[0]
if head in skip or head in RETIRED:
continue
out.append(path)
# AGENTS.md лежит рядом с CLAUDE.md и читается теми же агентами: он почти
# стандарт, и проект вправе держать оба. Обязателен по-прежнему только
# первый.
for name in ("CLAUDE.md", "AGENTS.md"):
path = root / name
if path.exists():
out.append(path)
return out
def check_links(root: Path, rep: Report) -> None:
for path in canon_docs(root):
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
rep.error(f"{path.relative_to(root)} не читается: {exc}")
continue
for target in MD_LINK.findall(strip_code(text)):
target = target.strip()
if not target or target.startswith(("http://", "https://", "#", "mailto:")):
continue
clean = target.split("#", 1)[0]
if not clean:
continue
if (path.parent / clean).exists():
continue
rep.error(f"{path.relative_to(root)}: битая ссылка на {target}")
def check_placeholders_and_debt(root: Path, rep: Report) -> None:
for path in canon_docs(root):
text = strip_code(path.read_text(encoding="utf-8", errors="replace"))
rel = path.relative_to(root)
for what in PLACEHOLDER.findall(text):
# Замечание, а не дрейф: незаполненный канон — объявленное переходное
# состояние, и краснеть на нём значит требовать выдумать содержание.
rep.note(f"{rel}: плейсхолдер шаблона не заполнен — {what}")
for what in DEBT_MARKER.findall(text):
rep.debt(f"{rel}: {what}")
def theme_text(root: Path, name: str) -> str | None:
"""Текст темы целиком: файл или все markdown каталога, склеенные.
Проверке всё равно, одним файлом написана тема или десятью: она ищет
упоминание, а упоминание живёт в любом из них.
"""
home, _ = theme_home(root, name)
if home is None:
return None
if home.is_file():
return home.read_text(encoding="utf-8", errors="replace")
return "\n".join(
path.read_text(encoding="utf-8", errors="replace")
for path in sorted(home.rglob("*.md"))
)
def check_capabilities(root: Path, rep: Report) -> None:
specs = root / "openspec" / "specs"
text = theme_text(root, "architecture")
if not specs.is_dir():
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
return
if text is None:
rep.skip(
"темы architecture нет — capability не сверены с обзором "
"(об отсутствии сказано отдельной строкой)"
)
return
for d in sorted(specs.iterdir()):
if not d.is_dir():
continue
name = d.name
# Засчитываем только явное упоминание: ссылку на спеку или имя в обратных
# кавычках. Голая подстрока совпадает с именем пакета или CLI-команды и
# даёт ложное «упомянуто» — то есть проверку, проходящую не по той причине.
explicit = f"openspec/specs/{name}" in text or f"`{name}`" in text
loose = re.search(rf"\b{re.escape(name)}\b", text) is not None
if explicit:
continue
if loose:
rep.note(
f"capability {name}: в теме architecture есть слово «{name}», но "
f"нет ни ссылки на openspec/specs/{name}, ни имени в обратных "
f"кавычках — проверь, это про capability или про пакет"
)
else:
rep.error(
f"capability {name} есть в openspec/specs/, но не упомянута в "
f"теме architecture — обзор отстал от нормативных спек"
)
def changed_files(root: Path, base: str, rep: Report) -> list[str] | None:
"""Объединение закоммиченного, рабочего дерева и untracked.
Гейт гоняют ДО коммита, поэтому `base...HEAD` не видит ровно ту правку, ради
которой проверка и заводилась: миграция уже лежит в дереве, но ещё не в
истории. Пропущенная правка выглядела бы как зелёный шаг."""
cmds = [
["diff", "--name-only", base],
["ls-files", "--others", "--exclude-standard"],
]
seen: list[str] = []
for cmd in cmds:
try:
out = subprocess.run(
["git", "-C", str(root), *cmd],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
rep.skip(f"сверка миграций пропущена: git не отдал дифф ({exc})")
return None
seen.extend(line for line in out.stdout.splitlines() if line)
return sorted(set(seen))
def check_migrations(root: Path, cfg: dict, base: str | None, rep: Report) -> None:
migrations = cfg.get("migrations")
if not migrations:
rep.skip("в .pm.json нет ключа migrations — сверка со схемой неприменима")
return
if not base:
rep.skip("база диффа не названа (--base) — сверка миграций со схемой не гонялась")
return
changed = changed_files(root, base, rep)
if changed is None:
return
touched = [f for f in changed if f.startswith(migrations.rstrip("/") + "/")]
if not touched:
return
# Тема database бывает файлом и каталогом — правкой считается любой её файл.
if not any(
f == "docs/database.md" or f.startswith("docs/database/") for f in changed
):
rep.error(
f"миграции изменены ({len(touched)} файлов), а тема database — нет: "
f"схема в документации отстала"
)
def check_tasks(root: Path, rep: Report) -> None:
tasks = root / "docs" / "tasks"
if not tasks.is_dir():
rep.error("нет docs/tasks/ — каталог задач часть канона")
return
script = Path(__file__).resolve().parents[2] / "tasks" / "scripts" / "tasks.py"
if not script.exists():
rep.skip(f"tasks.py не найден по пути {script} — согласованность задач не проверена")
return
# cwd=root обязателен: tasks.py отвергает --dir вне текущего каталога, и без
# этого его отказ окружения (код 3) схлопнулся бы в наш дрейф (код 1).
proc = subprocess.run(
[sys.executable, str(script), "check", "--dir", "docs/tasks"],
capture_output=True,
text=True,
cwd=str(root),
)
if proc.returncode == 0:
return
if proc.returncode == 1:
rep.error("tasks.py check нашёл дрейф в docs/tasks/ — разбирать его командой tasks.py")
else:
# Чужой код выхода не выдаём за свой: 3 это окружение, а не дрейф.
rep.skip(
f"tasks.py check не отработал (код {proc.returncode}): "
f"{(proc.stderr or proc.stdout).strip().splitlines()[0] if (proc.stderr or proc.stdout).strip() else 'без сообщения'}"
)
# --- Отчёт ------------------------------------------------------------------
def report(rep: Report) -> int:
for msg in rep.errors:
print(f"ДРЕЙФ {msg}")
for msg in rep.notes:
print(f"ЗАМЕЧАНИЕ {msg}")
if rep.debts:
print(f"\nДОЛГ ({len(rep.debts)} маркеров, гейт от них не краснеет):")
for msg in rep.debts:
print(f" {msg}")
if rep.skipped:
print("\nНЕ ПРОВЕРЯЛОСЬ:")
for msg in rep.skipped:
print(f" {msg}")
print(
"\nМашина проверила раскладку, имена файлов, ссылки, версию и две сверки\n"
"с кодом. Согласованность документов между собой и с кодом она не\n"
"проверяет — это суждение агентов `doc-consistency` (документ ↔ документ\n"
"↔ openspec) и `doc-code-drift` (документ ↔ код)."
)
if rep.errors:
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
return DRIFT
print("\nИтог: канон соблюдён в механизируемой части.")
return OK
def cmd_check(args: argparse.Namespace) -> int:
root = Path(args.dir).resolve()
if not root.is_dir():
fail(ENV, f"каталог {root} не найден")
if not (root / "docs").exists() and not (root / "CLAUDE.md").exists():
fail(ENV, f"{root} не похож на корень проекта: нет ни docs/, ни CLAUDE.md")
rep = Report()
cfg = read_config(root, rep)
check_version(root, cfg, rep)
check_required(root, cfg, rep)
check_stray(root, rep)
check_slugs(root, rep)
check_links(root, rep)
check_placeholders_and_debt(root, rep)
check_capabilities(root, rep)
check_migrations(root, cfg, args.base, rep)
check_tasks(root, rep)
return report(rep)
def cmd_version(args: argparse.Namespace) -> int:
root = Path(args.dir).resolve()
cfg = read_config(root, Report())
got = cfg.get("canon", "не объявлена")
print(f"канон скрипта: {CANON_VERSION}")
print(f"канон проекта: {got}")
return OK
def main() -> int:
parser = argparse.ArgumentParser(
prog="docs.py",
description="механическая проверка канона документов проекта",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_check = sub.add_parser("check", help="раскладка, ссылки, версия, сверки с кодом")
p_check.add_argument("--dir", default=".", help="корень проекта (по умолчанию текущий)")
p_check.add_argument("--base", default=None, help="база диффа для сверки миграций")
p_check.set_defaults(func=cmd_check)
p_ver = sub.add_parser("version", help="версия канона скрипта и проекта")
p_ver.add_argument("--dir", default=".", help="корень проекта")
p_ver.set_defaults(func=cmd_version)
args = parser.parse_args()
try:
return args.func(args)
except SystemExit:
raise
except Exception as exc: # noqa: BLE001 — последний рубеж, код 4 по словарю
print(f"ВНУТРЕННИЙ СБОЙ: {exc}", file=sys.stderr)
return INTERNAL
if __name__ == "__main__":
sys.exit(main())