Files
dev-skills/av-dev-docs/skills/canon/scripts/docs.py
T
av b8120d3271 resolve: два сценария вместо одной цепочки — разведка и решение
Разведка была прологом к коду: три шага, чекпоинт вариантов — и вливание в
общую ветку. Своего исхода у неё не было, поэтому и писать в документы проекта
ей было незачем: ответ оседал в design.md будущего change.

- у разведки появился исход: ответ уезжает в документы канона, задачи заводятся
  и уточняются, написанное коммитится, запись закрывается. Кода сценарий не
  пишет вовсе, OpenSpec ему не нужен
- точка входа осталась одна, и сценарий выбирает скилл, прочитав постановку:
  «есть ли очевидный способ решения» видно после чтения записи, и требовать
  этого суждения от вызывающего значит требовать его раньше, чем оно возможно
- оба сценария лежат справочниками и одинаково — solve.md и research.md, — а в
  SKILL.md остались вход, развилка и правила, не зависящие от сценария.
  Асимметрия читалась бы как старшинство: сценарий в теле скилла выглядит
  основным, а в справочнике — оговоркой
- переход между сценариями — событие с названным исходом: решение, упёршееся в
  незнание способа, останавливается; разведка, выбравшая способ, доводится до
  конца, а код идёт следующим прогоном, который запускает человек
- канон 14: у ADR два законных источника. У решения, принятого разведкой,
  design.md нет по построению, и такое решение либо не попадало в adr/ вовсе,
  либо попадало сочинённым заново

Правки по своему же ревью, до коммита:

- версия 14 была неполной — разрез проверки, вход и устав doc-consistency,
  скелеты adr/README.md и template.md по-прежнему требовали ссылку на
  design.md. Агент краснел бы на законной записи; скелеты уезжают в проекты,
  поэтому запись журнала называет их поимённо
- canon.md объявлял себя двенадцатым, пережив версию 13. Литерал был третьим
  домом числа при двух исправных — убран, а не поправлен
- сценарий разведки был недостижим там, где обещал работать: ready требует у
  research оба раздела, включая «Куда ляжет ответ», а сценарий брался назначить
  адрес сам. Адрес назначает автор записи; сценарий — только когда записи нет
- разведка коммитила без гейта, хотя правит документы канона и индексы задач
- при переносе выпало предупреждение про закрытие разведки без записанного
  ответа — возвращено
2026-08-11 11:36:37 +03:00

667 lines
33 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 = 14
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Дом версии канона и путей, нужных проверкам. Имя — от плагина, который файл
# завёл: настройки канона документов ведёт `av-dev-docs`, и файл называется по
# нему. Прежнее имя досталось от `av-dev-pm` — плагина, который распался на
# четыре и которого больше нет; читать его скрипт не умеет намеренно, потому что
# два дома для версии канона расходятся молча, а переименование стоит одну
# команду и названо записью 13 журнала.
CONFIG = "docs/.docs.json"
LEGACY_CONFIG = "docs/.pm.json"
# --- Раскладка канона -------------------------------------------------------
# Документ канона: имя → (категория, на какой вопрос отвечает).
#
# Категории — из canon.md, раздел «Три категории документов». Разрез один: можно
# ли по документу сказать «в этом изменении сделано не так»?
# тема — да, прямо: документ заводит направление проверки изменения;
# источник — нет, но он задаёт границу, по которой судит чужая тема;
# процессный — нет: он про то, как мы работаем, а не про изменение.
#
# **Категория не меняет обязательности документа** — заводятся все три
# одинаково и с первого дня. Она меняет только то, что с документом делает
# конвейер ревью, и потому печатается в отказе: «нет источника passport»
# читается иначе, чем «нет темы security», и чинится теми же руками, но с
# другим приоритетом.
#
# **Документ живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с
# README.md внутри.** Форму выбирает проект: документ разросся — стал каталогом,
# и это не смена канона и не повод править скрипт. Обе формы сразу — ошибка: это
# два дома для одного факта, ровно то, от чего канон и защищает.
DOCS = {
"passport": ("источник", "зачем и для кого, чем НЕ является"),
"architecture": ("тема", "как сложено — обзор, окружение, эксплуатация"),
"security": ("тема", "периметр, недоверенный вход, что вне модели"),
"conventions": ("тема", "как мы пишем код; индекс, промоут, что механизировано"),
"research": ("процессный", "что показала реальность: наблюдения и числа"),
"adr": ("процессный", "почему решено так; индекс, статусы, правило замены"),
"review": ("процессный", "настройка конвейера + журнал дефектов"),
}
# Документ, обязательный только при условии: имя → (ключ .docs.json, категория,
# пояснение).
CONDITIONAL_DOCS = {
"database": ("migrations", "источник", "схема хранилища и настройки"),
}
# Обязательные файлы вне раскладки docs/.
REQUIRED = {
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
CONFIG: "версия канона и пути, нужные проверкам",
}
# Файлы, которые документ-каталог обязан держать сверх README.md.
DOC_EXTRA = {
"adr": {"template.md": "шаблон записи ADR"},
}
# Служебное в docs/ и каталог задач, оставшийся там от прежней раскладки. Формы
# у них скрипт не проверяет, и по разным причинам: `.docs.json` не markdown, а
# задачи **принадлежат другому плагину** — `av-dev-tasks`, со своим скриптом,
# своим конфигом и своей версией формата (её сторожит `tasks.py check`).
#
# Дом задач с версии 11 — `tasks/` в корне репозитория, то есть вне `docs/`
# вовсе. `docs/tasks/` здесь терпится потому, что непереехавший проект не должен
# получать «файл вне канона» вдобавок к записи журнала, которая и так велит ему
# переехать. Внутрь скрипт не смотрит ни в том, ни в другом случае.
#
# Прежнее имя конфига терпится ровно за тем же: про переименование проект
# слышит одну строку — от `check_required`, — а не две, из которых вторая ещё и
# зовёт файл лишним.
NOT_DOCS = {".docs.json", ".pm.json", "tasks"}
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое. Имена,
# совпадающие с темой, отсюда убраны намеренно: `docs/conventions.md` и
# `docs/review/` теперь законные формы своих тем.
RETIRED = {
"review-brief.md": "документы канона и есть бриф; остаток — в review",
"review-journal.md": "→ документ review",
"plan.md": "→ tasks/ROADMAP.md (плагин av-dev-tasks)",
"local-research.md": "→ документ research",
"specs": "поведение → openspec/specs/, обзор → тема architecture",
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
"backlog": "→ tasks/ в корне репозитория (плагин av-dev-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"<!--\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](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 / CONFIG
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(ENV, f"{CONFIG} не разбирается: {exc}")
if not isinstance(data, dict):
fail(ENV, f"{CONFIG} должен быть объектом")
return data
# --- Проверки ---------------------------------------------------------------
def check_version(root: Path, cfg: dict, rep: Report) -> None:
if not (root / CONFIG).exists():
return # об отсутствии файла скажет check_required, второй раз не нужно
if "canon" not in cfg:
rep.error(f{CONFIG} нет ключа canon — версия канона не объявлена")
return
got = cfg["canon"]
if not isinstance(got, int):
rep.error(f"canon в {CONFIG} должен быть целым числом, а не {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 (root / rel).exists():
continue
# Файл под прежним именем — это не «нет файла», а незаконченный переезд,
# и чинится он одной командой. Без этой ветки проект услышал бы «нет
# версии канона» и пошёл заводить второй файл рядом с первым.
if rel == CONFIG and (root / LEGACY_CONFIG).exists():
rep.error(
f"нет {rel}{what}. Настройки лежат под прежним именем"
f" {LEGACY_CONFIG} (от плагина av-dev-pm, которого больше нет):"
f" `git mv {LEGACY_CONFIG} {rel}` — журнал канона, версия 13."
f" Прежнее имя не читается, поэтому в этом прогоне всё"
f" остальное проверено так, будто настроек нет вовсе"
)
continue
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" (обязателен: в .docs.json объявлен {key})"
)
elif key not in cfg and home is None:
rep.skip(f"{name} — в .docs.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 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("в .docs.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 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"
"(`av-dev-code:openspec`, команда `openspec.py check`). Согласованность\n"
"документов между собой и с кодом — тоже не её: это суждение агентов\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_capabilities(root, rep)
check_migrations(root, cfg, args.base, 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())