Слияние ничего из идей не тронуло, но сделало дешёвым дом для правила, натянутого между скиллами. Заведён shared/axes.md — дом перечня, а не значений: девять осей, их адреса и чего каждая не решает. Механика остаётся у владельца. Целиком сюда переехали две оси, у которых владельца не было. Коды выхода объявлялись общим словарём в одиннадцати местах, и каждое объявление называло свой набор соседей; машина их не сверяла, потому что copies.py смотрит markdown, а перечни лежали в docstring'ах. Теперь дом один, скрипты держат указатель, а три SKILL.md — помеченную копию, потому что на кодах они ветвятся. Режим прогона (с меткой, без метки) был размазан по четырём файлам и осью назван не был, хотя в уставе review-basics задаёт саму возможность запуска. Разведены два значения слова «стадия»: ступени 1-5 внутри прогона кода, стадии дизайна и кода снаружи. Карта нашла ошибку в себе: клетка «категория документа × метка» пустой не была — review-basics приёмник проектных тем при любой метке. Пустой оказалась соседняя: на прогоне без метки план фиксирован, и своих тем проекта в нём нет вовсе. Обе оставшиеся пустоты названы вслух, а не заполнены наугад.
743 lines
38 KiB
Python
743 lines
38 KiB
Python
#!/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:doc-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:doc-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())
|