Скрипты остаются на голом python 3.12 без зависимостей: pyproject.toml живёт только в этом репозитории и держит линтеры, а не зависимости скриптов. Ноль зависимостей охраняется дважды: banned-api у ruff ловит частые соблазны по имени, pyrefly видит окружение без ничего и не разрешает любой сторонний импорт. Версии прибиты точно, uv.lock под git: обновление линтера меняет набор находок, а находки правятся руками в скриптах, которые уезжают в чужие проекты. RUF001–003 выключены — весь текст русский, 311 срабатываний из 338 шум. av-dev-backlog исключён: заморожен до удаления, правка без выгоды. Из 41 находки содержательных две: мёртвая ques в check и два места, где find_entry_index может вернуть None прямо в list.pop и range. По ревью там стоит raise, а не continue: тихий пропуск превратил бы сломанный инвариант в отчёт «индексы согласованы». os из tasks.py ушёл целиком, fail() в docs.py объявлен NoReturn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
442 lines
19 KiB
Python
442 lines
19 KiB
Python
#!/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 = 1
|
||
|
||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||
|
||
# --- Раскладка канона -------------------------------------------------------
|
||
|
||
# Обязательные файлы: путь → на какой вопрос отвечает (для внятного отказа).
|
||
REQUIRED = {
|
||
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
|
||
"docs/.pm.json": "версия канона и пути, нужные проверкам",
|
||
"docs/passport.md": "зачем и для кого, чем НЕ является",
|
||
"docs/architecture.md": "как сложено — обзор, окружение, эксплуатация",
|
||
"docs/security.md": "периметр, недоверенный вход, что вне модели",
|
||
"docs/review.md": "настройка конвейера + журнал дефектов",
|
||
"docs/conventions/README.md": "индекс конвенций, правило промоута, что механизировано",
|
||
"docs/research/README.md": "как снималось, индекс наблюдений",
|
||
"docs/adr/README.md": "индекс записей, статусы, правило замены",
|
||
"docs/adr/template.md": "шаблон записи ADR",
|
||
}
|
||
|
||
# Обязателен только при условии: путь → (ключ .pm.json, пояснение).
|
||
CONDITIONAL = {
|
||
"docs/database.md": ("migrations", "схема хранилища и настройки"),
|
||
}
|
||
|
||
# Что вообще разрешено лежать в docs/ верхним уровнем.
|
||
ALLOWED_FILES = {
|
||
".pm.json",
|
||
"passport.md",
|
||
"architecture.md",
|
||
"database.md",
|
||
"security.md",
|
||
"review.md",
|
||
}
|
||
ALLOWED_DIRS = {"conventions", "research", "adr", "tasks"}
|
||
|
||
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое.
|
||
RETIRED = {
|
||
"review-brief.md": "документы канона и есть бриф; остаток — в review.md",
|
||
"review-journal.md": "→ docs/review.md",
|
||
"plan.md": "→ docs/tasks/PLAN.md",
|
||
"conventions.md": "→ docs/conventions/",
|
||
"local-research.md": "→ docs/research/",
|
||
"research.md": "→ docs/research/",
|
||
"specs": "поведение → openspec/specs/, обзор → docs/architecture.md",
|
||
"drafts": "идея → задача [idea], отказ → ADR, порядок → PLAN.md",
|
||
"backlog": "→ docs/tasks/",
|
||
"review": "→ docs/review.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 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 rel, (key, what) in CONDITIONAL.items():
|
||
if key in cfg and not (root / rel).exists():
|
||
rep.error(f"нет {rel} — {what} (обязателен: в .pm.json объявлен {key})")
|
||
elif key not in cfg and not (root / rel).exists():
|
||
rep.skip(f"{rel} — в .pm.json нет ключа {key}, проверка неприменима")
|
||
|
||
|
||
def check_stray(root: Path, rep: Report) -> None:
|
||
docs = root / "docs"
|
||
if not docs.is_dir():
|
||
rep.error("нет каталога docs/")
|
||
return
|
||
for entry in sorted(docs.iterdir()):
|
||
name = entry.name
|
||
if name in RETIRED:
|
||
rep.error(f"docs/{name} — слота нет в каноне: {RETIRED[name]}")
|
||
continue
|
||
if entry.is_dir():
|
||
if name not in ALLOWED_DIRS:
|
||
rep.error(f"docs/{name}/ — каталог вне канона")
|
||
elif name not in ALLOWED_FILES:
|
||
rep.error(f"docs/{name} — файл вне канона")
|
||
|
||
|
||
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)
|
||
claude = root / "CLAUDE.md"
|
||
if claude.exists():
|
||
out.append(claude)
|
||
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 check_capabilities(root: Path, rep: Report) -> None:
|
||
specs = root / "openspec" / "specs"
|
||
arch = root / "docs" / "architecture.md"
|
||
if not specs.is_dir():
|
||
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
|
||
return
|
||
if not arch.exists():
|
||
rep.skip(
|
||
"docs/architecture.md нет — capability не сверены с обзором "
|
||
"(об отсутствии файла сказано отдельной строкой)"
|
||
)
|
||
return
|
||
text = arch.read_text(encoding="utf-8", errors="replace")
|
||
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}: в docs/architecture.md есть слово «{name}», но "
|
||
f"нет ни ссылки на openspec/specs/{name}, ни имени в обратных "
|
||
f"кавычках — проверь, это про capability или про пакет"
|
||
)
|
||
else:
|
||
rep.error(
|
||
f"capability {name} есть в openspec/specs/, но не упомянута в "
|
||
f"docs/architecture.md — обзор отстал от нормативных спек"
|
||
)
|
||
|
||
|
||
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
|
||
if "docs/database.md" not in changed:
|
||
rep.error(
|
||
f"миграции изменены ({len(touched)} файлов), а docs/database.md — нет: "
|
||
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"
|
||
"Смысловые дубли, оставшееся в architecture.md поведение и достаточность\n"
|
||
"честной строки в пустом слоте она не проверяет — это суждение агента."
|
||
)
|
||
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_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())
|