конфиг: одна версия и один служебный файл, .av-dev.toml в корне
Версий было две — канон 14 в docs/.docs.json и формат задач 1 в <каталог задач>/.tasks.json, — и порознь они двигались потому, что плагины ставились порознь. Плагин один, версия одна и начинается с 1; журналы обеих прежних нумераций закрыты и лежат рядом непереписанными, действующий журнал открывается записью о слиянии с перечнем шагов проекту. Формат TOML взят ради комментариев: файл живёт в репозитории проекта, и назначение числа читают из него самого. Отсюда правило записи — скрипты правят строку, а не переписывают файл. Читатель общий, shared/config.py: два разбора одной схемы были бы двумя домами. Каталог задач перестал узнаваться служебным файлом и называется ключом [tasks] dir; узнают его по индексу. Прежние файлы не читаются — увидев их, docs.py и tasks.py называют прежнюю раскладку и зовут upgrade.
This commit is contained in:
@@ -17,26 +17,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
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
|
||||
|
||||
CANON_VERSION = 14
|
||||
|
||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||
|
||||
# Дом версии канона и путей, нужных проверкам. Имя — от плагина, который файл
|
||||
# завёл: настройки канона документов ведёт `av-dev-docs`, и файл называется по
|
||||
# нему. Прежнее имя досталось от `av-dev-pm` — плагина, который распался на
|
||||
# четыре и которого больше нет; читать его скрипт не умеет намеренно, потому что
|
||||
# два дома для версии канона расходятся молча, а переименование стоит одну
|
||||
# команду и названо записью 13 журнала.
|
||||
CONFIG = "docs/.docs.json"
|
||||
LEGACY_CONFIG = "docs/.pm.json"
|
||||
|
||||
def _load_shared() -> ModuleType:
|
||||
"""Общий читатель `.av-dev.toml` — `shared/config.py` этого же плагина.
|
||||
|
||||
Путь считается от файла скрипта, а не от рабочего каталога: скрипт зовут из
|
||||
репозитория проекта, где ни плагина, ни его дерева в текущем каталоге нет.
|
||||
Своё дерево — единственное, куда ходить можно; в чужое не ходим никогда.
|
||||
"""
|
||||
path = Path(__file__).resolve().parents[3] / "shared" / "config.py"
|
||||
spec = importlib.util.spec_from_file_location("avdev_config", path)
|
||||
if 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`: её знают оба
|
||||
# скрипта, и второе число здесь было бы вторым домом.
|
||||
CANON_VERSION = conf.VERSION
|
||||
|
||||
# Дом версии и путей, нужных проверкам, — `.av-dev.toml` в корне репозитория.
|
||||
# До слияния плагинов файлов было два, `docs/.docs.json` и `.tasks.json`, и
|
||||
# версии двигались порознь; теперь дом один, и лежит он в корне, потому что
|
||||
# настройки нужны и проекту без `docs/`.
|
||||
CONFIG = conf.CONFIG_NAME
|
||||
|
||||
# --- Раскладка канона -------------------------------------------------------
|
||||
|
||||
@@ -77,7 +98,7 @@ CONDITIONAL_DOCS = {
|
||||
# Обязательные файлы вне раскладки docs/.
|
||||
REQUIRED = {
|
||||
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
|
||||
CONFIG: "версия канона и пути, нужные проверкам",
|
||||
CONFIG: "версия раскладки av-dev и пути, нужные проверкам",
|
||||
}
|
||||
|
||||
# Файлы, которые документ-каталог обязан держать сверх README.md.
|
||||
@@ -86,9 +107,10 @@ DOC_EXTRA = {
|
||||
}
|
||||
|
||||
# Служебное в docs/ и каталог задач, оставшийся там от прежней раскладки. Формы
|
||||
# у них скрипт не проверяет, и по разным причинам: `.docs.json` не markdown, а
|
||||
# задачи **принадлежат другому плагину** — `av-dev-tasks`, со своим скриптом,
|
||||
# своим конфигом и своей версией формата (её сторожит `tasks.py check`).
|
||||
# у них скрипт не проверяет, и по разным причинам: `.docs.json` не markdown (и
|
||||
# сам он теперь след прежней раскладки, о котором говорит `check_required`), а
|
||||
# задачи ведёт **другой скилл** — `task-track`, со своим скриптом и своими
|
||||
# проверками.
|
||||
#
|
||||
# Дом задач с версии 11 — `tasks/` в корне репозитория, то есть вне `docs/`
|
||||
# вовсе. `docs/tasks/` здесь терпится потому, что непереехавший проект не должен
|
||||
@@ -106,11 +128,11 @@ NOT_DOCS = {".docs.json", ".pm.json", "tasks"}
|
||||
RETIRED = {
|
||||
"review-brief.md": "документы канона и есть бриф; остаток — в review",
|
||||
"review-journal.md": "→ документ review",
|
||||
"plan.md": "→ tasks/ROADMAP.md (плагин av-dev-tasks)",
|
||||
"plan.md": "→ tasks/ROADMAP.md (ведёт скилл task-track)",
|
||||
"local-research.md": "→ документ research",
|
||||
"specs": "поведение → openspec/specs/, обзор → тема architecture",
|
||||
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
|
||||
"backlog": "→ tasks/ в корне репозитория (плагин av-dev-tasks)",
|
||||
"backlog": "→ tasks/ в корне репозитория (ведёт скилл task-track)",
|
||||
}
|
||||
|
||||
# --- Слаги в именах файлов --------------------------------------------------
|
||||
@@ -264,16 +286,15 @@ def fail(code: int, msg: str) -> NoReturn:
|
||||
|
||||
|
||||
def read_config(root: Path, rep: Report) -> dict:
|
||||
path = root / CONFIG
|
||||
if not path.exists():
|
||||
return {}
|
||||
"""Настройки проекта целиком; проверкам канона нужна секция `[docs]`."""
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
fail(ENV, f"{CONFIG} не разбирается: {exc}")
|
||||
if not isinstance(data, dict):
|
||||
fail(ENV, f"{CONFIG} должен быть объектом")
|
||||
return data
|
||||
return conf.read(root)
|
||||
except conf.ConfigError as exc:
|
||||
fail(ENV, str(exc))
|
||||
|
||||
|
||||
def docs_cfg(cfg: dict) -> dict:
|
||||
return conf.section(cfg, "docs")
|
||||
|
||||
|
||||
# --- Проверки ---------------------------------------------------------------
|
||||
@@ -282,22 +303,19 @@ def read_config(root: Path, rep: Report) -> dict:
|
||||
def check_version(root: Path, cfg: dict, rep: Report) -> None:
|
||||
if not (root / CONFIG).exists():
|
||||
return # об отсутствии файла скажет check_required, второй раз не нужно
|
||||
if "canon" not in cfg:
|
||||
rep.error(f"в {CONFIG} нет ключа canon — версия канона не объявлена")
|
||||
return
|
||||
got = cfg["canon"]
|
||||
if not isinstance(got, int):
|
||||
rep.error(f"canon в {CONFIG} должен быть целым числом, а не {got!r}")
|
||||
got = conf.version(cfg)
|
||||
if got is None:
|
||||
rep.error(f"в {CONFIG} нет ключа version — версия раскладки не объявлена")
|
||||
return
|
||||
if got < CANON_VERSION:
|
||||
rep.error(
|
||||
f"проект приведён к канону версии {got}, текущая — {CANON_VERSION}: "
|
||||
f"проект приведён к раскладке версии {got}, текущая — {CANON_VERSION}: "
|
||||
f"нужен canon upgrade"
|
||||
)
|
||||
elif got > CANON_VERSION:
|
||||
rep.error(
|
||||
f"проект приведён к канону версии {got}, а скрипт знает {CANON_VERSION}: "
|
||||
f"устарел плагин, обнови маркетплейс"
|
||||
f"проект приведён к раскладке версии {got}, а скрипт знает"
|
||||
f" {CANON_VERSION}: устарел плагин, обнови маркетплейс"
|
||||
)
|
||||
|
||||
|
||||
@@ -331,15 +349,17 @@ def check_required(root: Path, cfg: dict, rep: Report) -> None:
|
||||
for rel, what in REQUIRED.items():
|
||||
if (root / rel).exists():
|
||||
continue
|
||||
# Файл под прежним именем — это не «нет файла», а незаконченный переезд,
|
||||
# и чинится он одной командой. Без этой ветки проект услышал бы «нет
|
||||
# версии канона» и пошёл заводить второй файл рядом с первым.
|
||||
if rel == CONFIG and (root / LEGACY_CONFIG).exists():
|
||||
# Настройки под прежними именами — это не «нет файла», а незаконченный
|
||||
# переезд. Без этой ветки проект слышал бы «нет версии» и шёл заводить
|
||||
# второй файл рядом с первым, а старые остались бы вторым домом.
|
||||
legacy = conf.legacy_files(root)
|
||||
if rel == CONFIG and legacy:
|
||||
rep.error(
|
||||
f"нет {rel} — {what}. Настройки лежат под прежним именем"
|
||||
f" {LEGACY_CONFIG} (от плагина av-dev-pm, которого больше нет):"
|
||||
f" `git mv {LEGACY_CONFIG} {rel}` — журнал канона, версия 13."
|
||||
f" Прежнее имя не читается, поэтому в этом прогоне всё"
|
||||
f"нет {rel} — {what}. Настройки лежат по прежней раскладке"
|
||||
f" ({', '.join(legacy)}) — она осталась от трёх плагинов, которые"
|
||||
f" слились в один: перенеси значения и удали старые файлы"
|
||||
f" операцией upgrade скилла av-dev:doc-canon (журнал, версия 1)."
|
||||
f" Прежние имена не читаются, поэтому в этом прогоне всё"
|
||||
f" остальное проверено так, будто настроек нет вовсе"
|
||||
)
|
||||
continue
|
||||
@@ -360,18 +380,20 @@ def check_required(root: Path, cfg: dict, rep: Report) -> None:
|
||||
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 cfg and home is None:
|
||||
if key in docs and home is None:
|
||||
rep.error(
|
||||
f"нет документа {name} (docs/{name}.md или docs/{name}/),"
|
||||
f" категория «{kind}» — {what}"
|
||||
f" (обязателен: в .docs.json объявлен {key})"
|
||||
f" (обязателен: в {CONFIG} объявлен [docs] {key})"
|
||||
)
|
||||
elif key not in cfg and home is None:
|
||||
rep.skip(f"{name} — в .docs.json нет ключа {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:
|
||||
@@ -551,9 +573,10 @@ 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")
|
||||
migrations = docs_cfg(cfg).get("migrations")
|
||||
if not migrations:
|
||||
rep.skip("в .docs.json нет ключа migrations — сверка со схемой неприменима")
|
||||
rep.skip(f"в {CONFIG} нет ключа [docs] migrations —"
|
||||
f" сверка со схемой неприменима")
|
||||
return
|
||||
if not base:
|
||||
rep.skip("база диффа не названа (--base) — сверка миграций со схемой не гонялась")
|
||||
@@ -630,9 +653,9 @@ def cmd_check(args: argparse.Namespace) -> int:
|
||||
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}")
|
||||
got = conf.version(cfg)
|
||||
print(f"раскладка скрипта: {CANON_VERSION}")
|
||||
print(f"раскладка проекта: {got if got is not None else 'не объявлена'}")
|
||||
return OK
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user