Каталог openspec/ был предпосылкой, о которой канон говорил, но за которой не следил. openspec/specs/ объявлен домом темы requirements, config.yaml описан абзацем — а заводилось всё руками, и не проверялось ничего. Новый проект выходил из init с полным каноном документов и без каталога, без которого не работают ни opsx:propose, ни ревью дизайна, ни сверка требований. Теперь init делает openspec init --tools claude шагом 3, до первого документа, а adopt заводит его тем же способом, если на переводимом проекте его нет. Команда названа поимённо в трёх местах — скилле, каноне и отказе docs.py: отказ без команды заставляет искать её в другом месте. Файл из коробки оказался хуже отсутствующего, и потому проверяется машиной. openspec init кладёт config.yaml, где context и rules — закомментированный пример на английском. Такой файл читается как настроенный: он есть, он валиден, имя правильное. Работает он как пустой, и узнаётся это по уже написанному предложению — на другом языке, с capability по имени пакета, без единого SHALL. docs.py проверяет четыре вещи, каждая про молчащий пробел: каталог есть; имя именно config.yaml (config.yml OpenSpec не читает и об этом не сообщает); context и rules.specs не остались примером, а правила называют SHALL; context называет passport и CLAUDE.md. Последние два обязательны по порядку работы: предложение пишется до того, как кто-либо откроет docs/, и без этих строк его пишут, не зная ни границы домена, ни инвариантов. Форма конфига записана скелетом и сформулирована разрезом: утверждение, которое можно опровергнуть, открыв другой файл проекта, — пересказ; строка, которая говорит, какой файл открыть, — ссылка. Машина этот разрез не проверяет, отличить одно от другого она не умеет; он отдан doc-consistency отдельным абзацем правила «один факт — один дом», и config.yaml добавлен ему во вход. Место второго дома там самое частое: context читается при порождении каждого артефакта, туда удобно дописать «чтобы агент знал», и так заводятся копии инвариантов, конвенций, состава гейта и правил ревью. Образец лёг в канон, а не в конвейер, как планировало решение C: форма документа принадлежит владельцу канона документов, конвейер её читатель. Иначе av-dev-pipeline завёл бы описание файла, который заводит и проверяет av-dev-pm. Канон повышен до версии 7 с записью, выполнимой upgrade: завести openspec, привести config.yaml к скелету, вычистить из context пересказ, проверить имя файла, поднять номер в .pm.json. Проверка прогнана на четырёх фикстурах — свежий openspec init, два живых проекта и пустой каталог; отличает все четыре случая. Решение — 47. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
749 lines
36 KiB
Python
749 lines
36 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 = 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", "инварианты и семантика гейта останутся непрочитанными"),
|
||
]
|
||
|
||
# Служебное в 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"<!--\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 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 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("в openspec/config.yaml нет ключа schema — ожидается spec-driven")
|
||
elif schema.group(1) != "spec-driven":
|
||
rep.error(
|
||
f"schema в openspec/config.yaml — {schema.group(1)}, а канон описан "
|
||
f"для spec-driven"
|
||
)
|
||
|
||
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 — "
|
||
"требование без этого литерала валидатор отвергает, а правило "
|
||
"проекта об этом молчит"
|
||
)
|
||
|
||
|
||
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 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())
|