скиллы: doc-canon стал canon, версия раскладки поднята до 2
- каталог скилла и все вызовы переименованы: префикс `doc-` называл материал, а скилл занят формой — раскладкой всех частей проекта и общим повышением версии, включая каталог задач; - README перестроен: `canon` вынесен из семейства документов отдельным блоком и отдельным узлом графа, правило префиксов переформулировано, у документов уточнено владение — содержимым, а не раскладкой; - заведена запись 2 журнала версий: в проекте ничего не переехало, но путь к `docs.py` и имя вызова живут в гейте и в `CLAUDE.md` проекта и сломаются молча; - прежние адреса в записи 1 и в журнале решений оставлены как есть: журнал описывает состояния, которые были, и задним числом не переписывается.
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка раскладки документов проекта против канона av-dev.
|
||||
|
||||
Определение канона — references/canon.md рядом со скриптом. Здесь только
|
||||
механизируемая часть: пути, лишние файлы, битые ссылки, версия, плейсхолдеры,
|
||||
маркеры долга и две сверки с кодом. Смысловые дубли и оставшееся в архитектуре
|
||||
поведение судит агент — скрипт об этом говорит вслух в конце отчёта.
|
||||
|
||||
Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф
|
||||
против окружения» — av-dev/shared/axes.md. Значения — в константах ниже.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import NoReturn
|
||||
|
||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||
|
||||
|
||||
def _load_shared() -> ModuleType:
|
||||
"""Общий читатель `.av-dev.toml` — `shared/config.py` этого же плагина.
|
||||
|
||||
Путь считается от файла скрипта, а не от рабочего каталога: скрипт зовут из
|
||||
репозитория проекта, где ни плагина, ни его дерева в текущем каталоге нет.
|
||||
Своё дерево — единственное, куда ходить можно; в чужое не ходим никогда.
|
||||
"""
|
||||
path = Path(__file__).resolve().parents[3] / "shared" / "config.py"
|
||||
# Проверка именно файлом: `spec_from_file_location` на отсутствующем пути
|
||||
# возвращает исправный спек, и падает уже `exec_module` — трейсбеком и кодом
|
||||
# 1, то есть «найден дрейф, чинится». Битая установка дрейфом не является.
|
||||
spec = importlib.util.spec_from_file_location("avdev_config", path)
|
||||
if not path.is_file() or spec is None or spec.loader is None:
|
||||
print(f"ОТКАЗ: не читается {path} — общий читатель настроек;"
|
||||
f" переустанови плагин av-dev", file=sys.stderr)
|
||||
sys.exit(ENV)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
conf = _load_shared()
|
||||
|
||||
# Версия раскладки одна на плагин и живёт в `shared/config.py`: её знают оба
|
||||
# скрипта, и второе число здесь было бы вторым домом.
|
||||
LAYOUT_VERSION = conf.VERSION
|
||||
|
||||
# Дом версии и путей, нужных проверкам, — `.av-dev.toml` в корне репозитория.
|
||||
# До слияния плагинов файлов было два, `docs/.docs.json` и `.tasks.json`, и
|
||||
# версии двигались порознь; теперь дом один, и лежит он в корне, потому что
|
||||
# настройки нужны и проекту без `docs/`.
|
||||
CONFIG = conf.CONFIG_NAME
|
||||
|
||||
# --- Раскладка канона -------------------------------------------------------
|
||||
|
||||
# Документ канона: имя → (категория, на какой вопрос отвечает).
|
||||
#
|
||||
# Категории — из 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: "версия раскладки av-dev и пути, нужные проверкам",
|
||||
}
|
||||
|
||||
# Файлы, которые документ-каталог обязан держать сверх README.md.
|
||||
DOC_EXTRA = {
|
||||
"adr": {"template.md": "шаблон записи ADR"},
|
||||
}
|
||||
|
||||
# Служебное в docs/ и каталог задач, оставшийся там от прежней раскладки. Формы
|
||||
# у них скрипт не проверяет, и по разным причинам: `.docs.json` не markdown (и
|
||||
# сам он теперь след прежней раскладки, о котором говорит `check_required`), а
|
||||
# задачи ведёт **другой скилл** — `task-track`, со своим скриптом и своими
|
||||
# проверками.
|
||||
#
|
||||
# Дом задач с версии 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 (ведёт скилл task-track)",
|
||||
"local-research.md": "→ документ research",
|
||||
"specs": "поведение → openspec/specs/, обзор → тема architecture",
|
||||
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
|
||||
"backlog": "→ tasks/ в корне репозитория (ведёт скилл task-track)",
|
||||
}
|
||||
|
||||
# --- Слаги в именах файлов --------------------------------------------------
|
||||
|
||||
# Текст документов русский, а **имена файлов английские, 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:
|
||||
"""Настройки проекта целиком; проверкам канона нужна секция `[docs]`."""
|
||||
try:
|
||||
cfg = conf.read(root)
|
||||
conf.check_keys(docs_cfg(cfg), DOCS_KEYS, "в секции [docs]")
|
||||
except conf.ConfigError as exc:
|
||||
fail(ENV, str(exc))
|
||||
return cfg
|
||||
|
||||
|
||||
# Ключи секции `[docs]`. Секцию знает этот скрипт, а не общий читатель: ключ
|
||||
# заводится вместе с проверкой, которая его читает.
|
||||
DOCS_KEYS = ("migrations",)
|
||||
|
||||
|
||||
def docs_cfg(cfg: dict) -> dict:
|
||||
return conf.section(cfg, "docs")
|
||||
|
||||
|
||||
# --- Проверки ---------------------------------------------------------------
|
||||
|
||||
|
||||
def check_version(root: Path, cfg: dict, rep: Report) -> None:
|
||||
if not (root / CONFIG).exists():
|
||||
return # об отсутствии файла скажет check_required, второй раз не нужно
|
||||
got = conf.version(cfg)
|
||||
if got is None:
|
||||
rep.error(f"в {CONFIG} нет ключа version — версия раскладки не объявлена")
|
||||
return
|
||||
if got < LAYOUT_VERSION:
|
||||
rep.error(
|
||||
f"проект приведён к раскладке версии {got}, текущая — {LAYOUT_VERSION}:"
|
||||
f" нужно повышение (скилл av-dev:canon, операция upgrade)"
|
||||
)
|
||||
elif got > LAYOUT_VERSION:
|
||||
rep.error(
|
||||
f"проект приведён к раскладке версии {got}, а скрипт знает"
|
||||
f" {LAYOUT_VERSION}: устарел плагин, обнови маркетплейс"
|
||||
)
|
||||
|
||||
|
||||
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_legacy(root: Path, rep: Report) -> None:
|
||||
"""Следы прежней раскладки — отдельная проверка, а не ветка отсутствия.
|
||||
|
||||
Пока она жила внутри «нового файла нет», половина переезда проходила молча:
|
||||
завели `.av-dev.toml`, старые файлы удалить забыли — и оба скрипта считали
|
||||
проект здоровым. Это ровно тот второй дом, против которого переезд и
|
||||
делался, и увидеть его можно только тогда, когда новый файл уже есть.
|
||||
"""
|
||||
legacy = conf.legacy_files(root)
|
||||
if not legacy:
|
||||
return
|
||||
if (root / CONFIG).is_file():
|
||||
rep.error(
|
||||
f"прежняя раскладка не убрана: {', '.join(legacy)} рядом с {CONFIG}."
|
||||
f" Эти файлы не читаются, и версия в них своя — второй дом для того"
|
||||
f" же числа. Удали их: переезд не закончен (журнал, версия 1, шаг 3)"
|
||||
)
|
||||
return
|
||||
rep.error(
|
||||
f"нет {CONFIG}, а настройки лежат по прежней раскладке"
|
||||
f" ({', '.join(legacy)}) — она осталась от трёх плагинов, которые"
|
||||
f" слились в один: перенеси значения и удали старые файлы операцией"
|
||||
f" upgrade скилла av-dev:canon (журнал, версия 1). Прежние имена не"
|
||||
f" читаются, поэтому в этом прогоне всё остальное проверено так, будто"
|
||||
f" настроек нет вовсе"
|
||||
)
|
||||
|
||||
|
||||
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 conf.legacy_files(root):
|
||||
continue # об этом уже сказала check_legacy, и подробнее
|
||||
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}")
|
||||
|
||||
docs = docs_cfg(cfg)
|
||||
for name, (key, kind, what) in CONDITIONAL_DOCS.items():
|
||||
home, complaint = doc_home(root, name)
|
||||
if complaint:
|
||||
rep.error(complaint)
|
||||
if key in docs and home is None:
|
||||
rep.error(
|
||||
f"нет документа {name} (docs/{name}.md или docs/{name}/),"
|
||||
f" категория «{kind}» — {what}"
|
||||
f" (обязателен: в {CONFIG} объявлен [docs] {key})"
|
||||
)
|
||||
elif key not in docs and home is None:
|
||||
rep.skip(f"{name} — в {CONFIG} нет ключа [docs] {key},"
|
||||
f" проверка неприменима")
|
||||
|
||||
|
||||
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 = docs_cfg(cfg).get("migrations")
|
||||
if not migrations:
|
||||
rep.skip(f"в {CONFIG} нет ключа [docs] migrations —"
|
||||
f" сверка со схемой неприменима")
|
||||
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_legacy(root, 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()
|
||||
if not root.is_dir():
|
||||
fail(ENV, f"нет каталога {root}")
|
||||
cfg = read_config(root, Report())
|
||||
got = conf.version(cfg)
|
||||
print(f"версия раскладки, скрипт: {LAYOUT_VERSION}")
|
||||
print(f"версия раскладки, проект: {got if got is not None else 'не объявлена'}")
|
||||
return OK
|
||||
|
||||
|
||||
def cmd_bump(args: argparse.Namespace) -> int:
|
||||
"""Поднять версию проекта до той, что знает скрипт. Последний шаг повышения.
|
||||
|
||||
Двигается **строка**, а не файл: комментарии в нём принадлежат проекту.
|
||||
Поднять раньше времени нельзя не потому, что скрипт не даст, а потому что
|
||||
число объявляет пройденными шаги журнала, которых никто не делал, — поэтому
|
||||
команда отдельная и зовётся руками, а `check --fix` этого не пишет.
|
||||
"""
|
||||
root = Path(args.dir).resolve()
|
||||
if not (root / CONFIG).is_file():
|
||||
fail(ENV, f"нет {root / CONFIG} — сперва заведи раскладку (adopt)")
|
||||
was = conf.version(read_config(root, Report()))
|
||||
if was == LAYOUT_VERSION:
|
||||
print(f"версия уже {LAYOUT_VERSION}, файл не тронут")
|
||||
return OK
|
||||
if was is not None and was > LAYOUT_VERSION:
|
||||
fail(ENV, f"проект на версии {was}, скрипт знает {LAYOUT_VERSION}:"
|
||||
f" устарел плагин, обнови маркетплейс")
|
||||
conf.set_version(root, LAYOUT_VERSION)
|
||||
print(f"версия раскладки: {was if was is not None else 'не была объявлена'}"
|
||||
f" → {LAYOUT_VERSION} в {CONFIG}")
|
||||
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)
|
||||
|
||||
p_bump = sub.add_parser("bump", help="поднять версию проекта до версии скрипта")
|
||||
p_bump.add_argument("--dir", default=".", help="корень проекта")
|
||||
p_bump.set_defaults(func=cmd_bump)
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user