#!/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 = 7 OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4 # --- Раскладка канона ------------------------------------------------------- # Документ канона: имя → (категория, на какой вопрос отвечает). # # Категории — из canon.md, раздел «Три категории документов». Разрез один: можно # ли по документу сказать «в этом изменении сделано не так»? # тема — да, прямо: документ заводит направление проверки изменения; # источник — нет, но он задаёт границу, по которой судит чужая тема; # процессный — нет: он про то, как мы работаем, а не про изменение. # # **Категория не меняет обязательности документа** — заводятся все три # одинаково и с первого дня. Она меняет только то, что с документом делает # конвейер ревью, и потому печатается в отказе: «нет источника passport» # читается иначе, чем «нет темы security», и чинится теми же руками, но с # другим приоритетом. # # **Документ живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с # README.md внутри.** Форму выбирает проект: документ разросся — стал каталогом, # и это не смена канона и не повод править скрипт. Обе формы сразу — ошибка: это # два дома для одного факта, ровно то, от чего канон и защищает. DOCS = { "passport": ("источник", "зачем и для кого, чем НЕ является"), "architecture": ("тема", "как сложено — обзор, окружение, эксплуатация"), "security": ("тема", "периметр, недоверенный вход, что вне модели"), "conventions": ("тема", "как мы пишем код; индекс, промоут, что механизировано"), "research": ("процессный", "что показала реальность: наблюдения и числа"), "adr": ("процессный", "почему решено так; индекс, статусы, правило замены"), "review": ("процессный", "настройка конвейера + журнал дефектов"), } # Документ, обязательный только при условии: имя → (ключ .pm.json, категория, # пояснение). CONDITIONAL_DOCS = { "database": ("migrations", "источник", "схема хранилища и настройки"), } # Обязательные файлы вне раскладки docs/. REQUIRED = { "CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта", "docs/.pm.json": "версия канона и пути, нужные проверкам", } # Файлы, которые документ-каталог обязан держать сверх README.md. DOC_EXTRA = { "adr": {"template.md": "шаблон записи ADR"}, } # Настройка OpenSpec. Команда заведения — она же в скилле init; здесь потому, # что её печатает отказ, а отказ без команды заставляет искать её в другом месте. OPENSPEC_INIT = "openspec init --tools claude" # Адреса, которые обязан назвать блок context. Не пересказ документов, а именно # ссылки: предложение пишется до того, как кто-либо откроет docs/, и без этих # двух строк его пишут, не зная ни границы домена, ни инвариантов. Список # короткий намеренно — длинный превращает context во второй дом фактов. OPENSPEC_POINTERS = [ ("passport", "граница домена и «чем НЕ является» останутся непрочитанными"), ("CLAUDE.md", "инварианты и семантика гейта останутся непрочитанными"), ] # --- Форма config.yaml сверена с живым OpenSpec ------------------------------ # # Три константы ниже — **слепок чужого инструмента**, а не наше решение. Схема, # перечень артефактов и версия, на которой это проверено, живут в OpenSpec и # меняются без нашего участия; здесь они записаны, чтобы проверка шла без запуска # node на каждом прогоне. # # Слепок стареет, и потому есть кто, кто это замечает: `check` сравнивает # major.minor установленного OpenSpec с OPENSPEC_CHECKED и, если они разошлись, # говорит замечанием «форма не перепроверена». Перепроверяет `docs.py # openspec-form` — он спрашивает сам инструмент и печатает, что разошлось. # Патч-версия сравнением намеренно не берётся: форма конфига в ней не меняется, а # замечание на каждый багфикс приучило бы пролистывать весь блок. OPENSPEC_CHECKED = "1.5" OPENSPEC_SCHEMA = "spec-driven" # Артефакты схемы. Ключ `rules:` адресуется артефакту, и адресованный # несуществующему **молча не действует** — ровно тот класс, ради которого вся # проверка и заведена. OPENSPEC_ARTIFACTS = ("proposal", "specs", "design", "tasks") # Служебное в docs/ и каталог, который ведёт tasks.py. Оба процессные, но # проверок формы у них нет: .pm.json не markdown, tasks/ ведёт другой скрипт. NOT_DOCS = {".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 DOCS} # Все документы-каталоги, включая свои темы проекта: правило имён общее, а # перечислять их поимённо значило бы закрыть открытый список. for folder in sorted(docs.iterdir()): if not folder.is_dir() or folder.name in NOT_DOCS: 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"") 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 doc_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, (kind, what) in DOCS.items(): home, complaint = doc_home(root, name) if home is None: rep.error( f"нет документа {name} (docs/{name}.md или docs/{name}/)," f" категория «{kind}» — {what}" ) continue if complaint: rep.error(complaint) if home.is_dir(): for extra, why in DOC_EXTRA.get(name, {}).items(): if not (home / extra).is_file(): rep.error(f"нет docs/{name}/{extra} — {why}") for name, (key, kind, what) in CONDITIONAL_DOCS.items(): home, complaint = doc_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}/)," f" категория «{kind}» — {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(DOCS) | set(CONDITIONAL_DOCS) 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_DOCS: continue topic = name[:-3] if entry.is_file() and name.endswith(".md") else name if topic 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(topic) 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 doc_text(root: Path, name: str) -> str | None: """Текст документа целиком: файл или все markdown каталога, склеенные. Проверке всё равно, одним файлом написан документ или десятью: она ищет упоминание, а упоминание живёт в любом из них. """ home, _ = doc_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 rules_keys(live: str) -> list[str]: """Имена артефактов, которым адресованы правила, — и только они. Идём от строки `rules:` до следующего ключа нулевой колонки, а не ищем отступ по всему файлу: блок `context: |` — литеральный скаляр, внутри него строки вида «Language: Russian» и «av-dev-pm:review-pipeline» выглядят ключами и дали бы находку на ровном месте. Проверено на живом конфиге, который так и падал. """ out: list[str] = [] inside = False for line in live.splitlines(): if not line.strip(): continue if not line[0].isspace(): inside = line.startswith("rules:") continue if not inside: continue m = re.fullmatch(r" ([A-Za-z_-]+):\s*", line) if m: out.append(m.group(1)) return out def check_openspec(root: Path, rep: Report) -> None: """Настройка OpenSpec заведена и не осталась примером из коробки. Разбираем текстом, а не YAML-парсером: у скриптов канона ноль внешних зависимостей, а PyYAML в стандартной библиотеке нет. Всё, что проверяется ниже, различимо построчно, и ложных срабатываний это не даёт: комментарии отброшены, ключи верхнего уровня стоят в первой колонке. """ os_dir = root / "openspec" if not os_dir.is_dir(): rep.error( "нет openspec/ — там дом темы requirements (openspec/specs/) и " f"настройка генерации артефактов; заводится `{OPENSPEC_INIT}`" ) return if (os_dir / "config.yml").is_file(): rep.error( "openspec/config.yml — читается только config.yaml, и этот файл " "останется незамеченным: настройка будет пустой, а выглядеть будет " "заполненной" ) path = os_dir / "config.yaml" if not path.is_file(): rep.error( "нет openspec/config.yaml — язык, правила именования capability и " "придирки валидатора будут заново угадываться на каждом предложении" ) return text = path.read_text(encoding="utf-8") live = "\n".join( line for line in text.splitlines() if not line.lstrip().startswith("#") ) keys = set(re.findall(r"(?m)^([A-Za-z_]+):", live)) schema = re.search(r"(?m)^schema:\s*(\S+)", live) if schema is None: rep.error( f"в openspec/config.yaml нет ключа schema — ожидается {OPENSPEC_SCHEMA}" ) elif schema.group(1) != OPENSPEC_SCHEMA: rep.error( f"schema в openspec/config.yaml — {schema.group(1)}, а канон описан " f"для {OPENSPEC_SCHEMA}" ) if "context" not in keys: rep.error( "в openspec/config.yaml нет ключа context: файл остался примером из " "коробки — предложение пишется без языка, правил именования " "capability и адресов документов проекта" ) else: for pointer, why in OPENSPEC_POINTERS: if pointer not in live: rep.error( f"openspec/config.yaml не называет {pointer} — {why}" ) if "rules" not in keys or "specs:" not in live: rep.error( "в openspec/config.yaml нет rules.specs — придирки валидатора " "нигде не записаны, и каждое предложение узнаёт их отказом" ) elif "SHALL" not in live: rep.error( "rules.specs в openspec/config.yaml не называет SHALL — " "требование без этого литерала валидатор отвергает, а правило " "проекта об этом молчит" ) # Ключ под rules: — имя артефакта схемы. Опечатка или устаревшее имя не # ломает ничего видимого: правила просто не применяются, а конфиг выглядит # написанным. for name in rules_keys(live): if name not in OPENSPEC_ARTIFACTS: rep.error( f"rules.{name} в openspec/config.yaml — такого артефакта у схемы " f"{OPENSPEC_SCHEMA} нет ({', '.join(OPENSPEC_ARTIFACTS)}): правила " f"под ним не применяются и молчат об этом" ) check_openspec_fresh(rep) def openspec_cli(args: list[str]) -> str | None: """Спросить сам инструмент. None — его нет или он не ответил.""" try: out = subprocess.run( ["openspec", *args], capture_output=True, text=True, timeout=30 ) except (FileNotFoundError, OSError, subprocess.SubprocessError): return None return out.stdout.strip() if out.returncode == 0 else None def check_openspec_fresh(rep: Report) -> None: """Не устарел ли наш слепок формы config.yaml. Стоит один запуск `openspec --version` — десятые доли секунды. Перечень артефактов и имя схемы отсюда не спрашиваются намеренно: они стоят втрое дороже, а меняются только вместе с версией, и потому за ними ходит отдельная команда `openspec-form`, а эта проверка говорит, когда её звать. """ got = openspec_cli(["--version"]) if got is None: rep.skip( "openspec не отвечает (нет на PATH?) — актуальность формы " "config.yaml не проверялась" ) return installed = ".".join(got.split(".")[:2]) if installed != OPENSPEC_CHECKED: rep.note( f"форма openspec/config.yaml сверена с OpenSpec {OPENSPEC_CHECKED}, " f"установлен {got}: перепроверить — `docs.py openspec-form`. Пока не " f"перепроверено, проверки формы судят по прежней схеме" ) def check_capabilities(root: Path, rep: Report) -> None: specs = root / "openspec" / "specs" text = doc_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" "openspec/config.yaml и две сверки с кодом. Согласованность документов\n" "между собой и с кодом она не проверяет — как и то, ссылается ли\n" "config.yaml на документы или пересказывает их. Это суждение агентов\n" "`doc-consistency` (документ ↔ документ ↔ openspec) и `doc-code-drift`\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_slugs(root, rep) check_links(root, rep) check_placeholders_and_debt(root, rep) check_openspec(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 cmd_openspec_form(args: argparse.Namespace) -> int: """Перепроверить слепок формы config.yaml по живому OpenSpec. Ничего не правит и не трогает проект: спрашивает инструмент и печатает, что разошлось с константами скрипта. Чинит человек — правкой констант, скелета в skeletons.md и записью в журнал версий канона, если форма действительно поменялась. """ version = openspec_cli(["--version"]) if version is None: fail( ENV, "openspec не отвечает: поставь его или проверь PATH — " "перепроверять форму нечем", ) raw = openspec_cli(["templates", "--json"]) if raw is None: fail(ENV, "`openspec templates --json` не отработал — схему не спросить") try: artifacts = tuple(json.loads(raw)) except json.JSONDecodeError as exc: fail(ENV, f"`openspec templates --json` отдал неразбираемое: {exc}") print(f"OpenSpec установлен: {version}") print(f"форма сверена с: {OPENSPEC_CHECKED}") print(f"артефакты схемы: {', '.join(artifacts)}") print(f"записано в скрипте: {', '.join(OPENSPEC_ARTIFACTS)}") diffs: list[str] = [] if ".".join(version.split(".")[:2]) != OPENSPEC_CHECKED: diffs.append( f"версия: поднять OPENSPEC_CHECKED до " f"{'.'.join(version.split('.')[:2])} — но только после того, как " f"остальные строки этого отчёта сойдутся" ) for name in artifacts: if name not in OPENSPEC_ARTIFACTS: diffs.append( f"новый артефакт {name}: решить, нужны ли ему правила в rules, " f"и добавить имя в OPENSPEC_ARTIFACTS" ) for name in OPENSPEC_ARTIFACTS: if name not in artifacts: diffs.append( f"артефакта {name} у схемы больше нет: правила под ним в конфигах " f"проектов молчат — убрать из OPENSPEC_ARTIFACTS, из скелета и " f"записать в журнал версий канона" ) print() if not diffs: print("Слепок сходится. Осталось глазами: не изменились ли придирки") print("валидатора — их скрипт проверить не может, они проявляются только") print("отказом `openspec validate --strict` на живой спеке.") return OK print("Разошлось:") for line in diffs: print(f" - {line}") print() print("Правится в трёх местах сразу: константы этого скрипта, скелет") print("`openspec/config.yaml` в skeletons.md и запись в changelog.md —") print("иначе проекты останутся на прежней форме молча.") return DRIFT 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) p_form = sub.add_parser( "openspec-form", help="перепроверить форму config.yaml по живому OpenSpec", ) p_form.set_defaults(func=cmd_openspec_form) 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())