канон 13: файл версии зовётся по владельцу, у задач появилась своя версия формата

Имя `.pm.json` пережило плагин `av-dev-pm` на два месяца и указывало в пустоту.
Правило, которое из этого вынуто: имя служебного файла — имя плагина, который
его завёл, и по нему же владельца узнают.

- `docs/.pm.json` → `docs/.docs.json`, запись 13 журнала. Прежнее имя docs.py
  не читает намеренно: по этому числу upgrade решает, какие записи применять,
  и два дома разъехались бы молча ровно там, где это дороже всего. Вместо
  совместимости — узнавание: check видит старый файл и печатает готовую git mv
- у каталога задач появилась своя версия формата — ключ `tasks` в
  `.tasks.json`, свой журнал версий и своё повышение. До сих пор её не было
  вовсе, хотя docs.py в комментарии уверенно на неё ссылался: описание
  опережало механику ровно так, как сказано в решении 195
- число своё, а не копия канонического: плагин ставится в одиночку, и у
  проекта без docs/ версии канона нет — сверять было бы не с чем
- конфиг задач стал обязательным (init и adopt apply пишут его всегда), check
  сверяет число, `check --fix` его не приписывает: приписанное объявляло бы
  каталог приведённым к формату, шагов которого никто не делал
- переезды 11 и 12 в новый журнал задним числом не переписаны — версия 1
  велит догнать формат по журналу канона, называя признаки отставания
  поимённо (каталог в docs/tasks/, живой SPRINT.md)
- запись 60 в DECISIONS со следствиями 200–203; отдельно разведено с решением
  F, где `.docs.json` отвергался как указатель путей: отвергнут был указатель,
  а не имя
This commit is contained in:
av
2026-08-11 10:35:39 +03:00
parent 12b77c3393
commit 863769406f
22 changed files with 474 additions and 93 deletions
+43 -17
View File
@@ -25,10 +25,19 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
CANON_VERSION = 12
CANON_VERSION = 13
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Дом версии канона и путей, нужных проверкам. Имя — от плагина, который файл
# завёл: настройки канона документов ведёт `av-dev-docs`, и файл называется по
# нему. Прежнее имя досталось от `av-dev-pm` — плагина, который распался на
# четыре и которого больше нет; читать его скрипт не умеет намеренно, потому что
# два дома для версии канона расходятся молча, а переименование стоит одну
# команду и названо записью 13 журнала.
CONFIG = "docs/.docs.json"
LEGACY_CONFIG = "docs/.pm.json"
# --- Раскладка канона -------------------------------------------------------
# Документ канона: имя → (категория, на какой вопрос отвечает).
@@ -59,7 +68,7 @@ DOCS = {
"review": ("процессный", "настройка конвейера + журнал дефектов"),
}
# Документ, обязательный только при условии: имя → (ключ .pm.json, категория,
# Документ, обязательный только при условии: имя → (ключ .docs.json, категория,
# пояснение).
CONDITIONAL_DOCS = {
"database": ("migrations", "источник", "схема хранилища и настройки"),
@@ -68,7 +77,7 @@ CONDITIONAL_DOCS = {
# Обязательные файлы вне раскладки docs/.
REQUIRED = {
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
"docs/.pm.json": "версия канона и пути, нужные проверкам",
CONFIG: "версия канона и пути, нужные проверкам",
}
# Файлы, которые документ-каталог обязан держать сверх README.md.
@@ -77,15 +86,19 @@ DOC_EXTRA = {
}
# Служебное в docs/ и каталог задач, оставшийся там от прежней раскладки. Формы
# у них скрипт не проверяет, и по разным причинам: `.pm.json` не markdown, а
# у них скрипт не проверяет, и по разным причинам: `.docs.json` не markdown, а
# задачи **принадлежат другому плагину** — `av-dev-tasks`, со своим скриптом,
# своим конфигом и своей версией формата.
# своим конфигом и своей версией формата (её сторожит `tasks.py check`).
#
# Дом задач с версии 11 — `tasks/` в корне репозитория, то есть вне `docs/`
# вовсе. `docs/tasks/` здесь терпится потому, что непереехавший проект не должен
# получать «файл вне канона» вдобавок к записи журнала, которая и так велит ему
# переехать. Внутрь скрипт не смотрит ни в том, ни в другом случае.
NOT_DOCS = {".pm.json", "tasks"}
#
# Прежнее имя конфига терпится ровно за тем же: про переименование проект
# слышит одну строку — от `check_required`, — а не две, из которых вторая ещё и
# зовёт файл лишним.
NOT_DOCS = {".docs.json", ".pm.json", "tasks"}
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое. Имена,
# совпадающие с темой, отсюда убраны намеренно: `docs/conventions.md` и
@@ -251,15 +264,15 @@ def fail(code: int, msg: str) -> NoReturn:
def read_config(root: Path, rep: Report) -> dict:
path = root / "docs" / ".pm.json"
path = root / CONFIG
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(ENV, f"docs/.pm.json не разбирается: {exc}")
fail(ENV, f"{CONFIG} не разбирается: {exc}")
if not isinstance(data, dict):
fail(ENV, "docs/.pm.json должен быть объектом")
fail(ENV, f"{CONFIG} должен быть объектом")
return data
@@ -267,14 +280,14 @@ def read_config(root: Path, rep: Report) -> dict:
def check_version(root: Path, cfg: dict, rep: Report) -> None:
if not (root / "docs" / ".pm.json").exists():
if not (root / CONFIG).exists():
return # об отсутствии файла скажет check_required, второй раз не нужно
if "canon" not in cfg:
rep.error("в docs/.pm.json нет ключа canon — версия канона не объявлена")
rep.error(f"в {CONFIG} нет ключа canon — версия канона не объявлена")
return
got = cfg["canon"]
if not isinstance(got, int):
rep.error(f"canon в docs/.pm.json должен быть целым числом, а не {got!r}")
rep.error(f"canon в {CONFIG} должен быть целым числом, а не {got!r}")
return
if got < CANON_VERSION:
rep.error(
@@ -316,8 +329,21 @@ def doc_home(root: Path, name: str) -> tuple[Path | None, str | 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}")
if (root / rel).exists():
continue
# Файл под прежним именем — это не «нет файла», а незаконченный переезд,
# и чинится он одной командой. Без этой ветки проект услышал бы «нет
# версии канона» и пошёл заводить второй файл рядом с первым.
if rel == CONFIG and (root / LEGACY_CONFIG).exists():
rep.error(
f"нет {rel}{what}. Настройки лежат под прежним именем"
f" {LEGACY_CONFIG} (от плагина av-dev-pm, которого больше нет):"
f" `git mv {LEGACY_CONFIG} {rel}` — журнал канона, версия 13."
f" Прежнее имя не читается, поэтому в этом прогоне всё"
f" остальное проверено так, будто настроек нет вовсе"
)
continue
rep.error(f"нет {rel}{what}")
for name, (kind, what) in DOCS.items():
home, complaint = doc_home(root, name)
@@ -342,10 +368,10 @@ def check_required(root: Path, cfg: dict, rep: Report) -> None:
rep.error(
f"нет документа {name} (docs/{name}.md или docs/{name}/),"
f" категория «{kind}» — {what}"
f" (обязателен: в .pm.json объявлен {key})"
f" (обязателен: в .docs.json объявлен {key})"
)
elif key not in cfg and home is None:
rep.skip(f"{name} — в .pm.json нет ключа {key}, проверка неприменима")
rep.skip(f"{name} — в .docs.json нет ключа {key}, проверка неприменима")
def check_stray(root: Path, rep: Report) -> None:
@@ -527,7 +553,7 @@ def changed_files(root: Path, base: str, rep: Report) -> list[str] | None:
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 — сверка со схемой неприменима")
rep.skip("в .docs.json нет ключа migrations — сверка со схемой неприменима")
return
if not base:
rep.skip("база диффа не названа (--base) — сверка миграций со схемой не гонялась")