#!/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"") PLACEHOLDER = re.compile(r"") 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())