канон 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
+106 -21
View File
@@ -10,7 +10,8 @@
Раскладка. Путь каталога — `tasks/` в корне репозитория, жёстко. Каталог
принадлежит этому плагину, а не канону документов: `docs/` ведёт другой плагин, и
проект, поставивший учёт работ без него, каталога `docs/` не имеет вовсе. Имена
внутри настраиваются через `tasks/.tasks.json`.
внутри и **версия формата** живут в `tasks/.tasks.json`; журнал версий —
references/changelog.md рядом со скриптом.
tasks/
items/ задачи и цели файлами, <slug>.md
@@ -107,8 +108,26 @@ import subprocess
import sys
from pathlib import Path
CONFIG_NAME = ".tasks.json" # дом настроек: свой файл в каталоге задач
PM_CONFIG_REL = "../.pm.json" # прежний дом: docs/.pm.json, ключ "tasks"
CONFIG_NAME = ".tasks.json" # дом настроек и версии: свой файл в каталоге
PM_CONFIG_REL = "../.pm.json" # прежний дом настроек: docs/.pm.json, ключ "tasks"
# Версия формата задач — **своя, а не канона документов**. Число живёт ключом
# `tasks` в `.tasks.json`, журнал версий — references/changelog.md рядом со
# скриптом, повышает его операция `upgrade` скилла `av-dev-tasks:tasks`.
#
# Число именно своё, потому что плагин ставится в одиночку: проект, взявший учёт
# работ без канона документов, каталога `docs/` не имеет вовсе, а значит не имеет
# и версии канона — сверять было бы не с чем. Копия чужого числа в этом скрипте
# была бы вторым домом для одной версии и разъехалась бы молча при обновлении
# одного плагина без другого.
#
# Переезды каталога задач, случившиеся до появления этого числа (в корень —
# канон 11, отмена спринтов — канон 12), задним числом сюда не переписаны: они
# уже названы журналом канона, и второй перечень тех же шагов разошёлся бы с
# первым. Версия 1 — формат на день её появления, что бы проекту ни пришлось
# пройти до неё.
FORMAT_VERSION = 1
VERSION_KEY = "tasks"
EXIT_OK = 0
EXIT_DRIFT = 1
@@ -433,7 +452,7 @@ class Layout:
def load_config(root: Path) -> dict:
"""Настройки каталога задач.
"""Настройки каталога задач и версия его формата.
Дом — `<каталог задач>/.tasks.json`: **свой файл у своего плагина**. Ключ
`tasks` в `docs/.pm.json` читается, пока живы проекты, заведённые до раскола
@@ -445,6 +464,10 @@ def load_config(root: Path) -> dict:
плагину. Проект, поставивший учёт задач без канона документов, каталога
`docs/` не имеет вовсе, и дом настроек, лежащий в чужом дереве, был бы домом,
которого у половины проектов нет.
Версия формата (ключ `tasks`) читается **только из своего файла**: прежний
дом её не знал и знать не может, и молча выведенная из его отсутствия версия
была бы догадкой о том, что чинится одной строкой.
"""
path = root / CONFIG_NAME
pm = (root / PM_CONFIG_REL).resolve()
@@ -485,7 +508,7 @@ def _read_json(path: Path) -> dict:
def _validate_config(data: dict, path: Path) -> dict:
unknown = set(data) - set(DEFAULTS)
unknown = set(data) - set(DEFAULTS) - {VERSION_KEY}
# Ключ «plan» был домом оглавления целей до того, как файл стал ROADMAP.md.
# Без этой ветки проект со старым конфигом получал бы «неизвестный ключ» и
# искал опечатку там, где на самом деле переименование канона.
@@ -495,9 +518,19 @@ def _validate_config(data: dict, path: Path) -> dict:
f" av-dev-docs:canon (upgrade), а не правь ключ в одиночку:"
f" файл и ссылки на него переезжают вместе с ним")
if unknown:
known = sorted({*DEFAULTS, VERSION_KEY})
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
f" (известны: {', '.join(sorted(DEFAULTS))})")
f" (известны: {', '.join(known)})")
# Версия — единственный ключ-число: остальные это имена файлов и заголовков.
# Битое число тут останавливает работу целиком (код 3), а не идёт дрейфом,
# потому что «на какой версии формата каталог» решает, чему верить дальше.
got = data.get(VERSION_KEY)
if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)):
raise Env(f"{path}: ключ «{VERSION_KEY}» — версия формата задач,"
f" ожидалось целое число, а не {got!r}")
for key, value in data.items():
if key == VERSION_KEY:
continue
if not isinstance(value, str) or not value.strip():
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
@@ -536,10 +569,46 @@ def config_problems(lay: Layout) -> list[str]:
return out
def version_problems(lay: Layout) -> list[str]:
"""Версия формата задач: объявлена ли и та ли, которую знает скрипт.
Отвечает на один вопрос — «по какой записи журнала повышать каталог», — и
ни на какой другой. Что запись оформлена по правилам своей версии, отсюда не
следует: число двигает тот, кто прошёл шаги, и соврать им так же легко, как
любой другой строкой. Цена вранья при этом низкая, а польза от вопроса есть
ровно там, где формат поменялся, а каталог остался прежним.
`check --fix` этого не чинит намеренно: приписать недостающее число значило
бы объявить каталог приведённым к формату, шагов которого никто не делал.
Заводит число `init`, двигает — операция `upgrade` скилла.
"""
path = lay.root / CONFIG_NAME
# Прежний дом (`docs/.pm.json`) версии не знает, поэтому спрашиваем строго
# свой файл: «конфиг нашёлся» и «версия объявлена» это разные события.
if not path.is_file():
return [f"нет {path} — версия формата задач не объявлена."
f" Заведи файл с «{VERSION_KEY}»: {FORMAT_VERSION} (журнал версий —"
f" references/changelog.md скилла av-dev-tasks:tasks)"]
# Что число целое, уже проверил `_validate_config` — иначе сюда не дошли бы
# вовсе (код 3). Здесь `isinstance` значит ровно «ключ есть».
got = lay.cfg.get(VERSION_KEY)
if not isinstance(got, int):
return [f"{path}: нет ключа «{VERSION_KEY}» — версия формата не объявлена,"
f" текущая {FORMAT_VERSION}"]
if got < FORMAT_VERSION:
return [f"каталог приведён к формату версии {got}, текущая —"
f" {FORMAT_VERSION}: нужно повышение по журналу"
f" (скилл av-dev-tasks:tasks, операция upgrade)"]
if got > FORMAT_VERSION:
return [f"каталог приведён к формату версии {got}, а скрипт знает"
f" {FORMAT_VERSION}: устарел плагин, обнови маркетплейс"]
return []
def looks_like_tasks(p: Path) -> bool:
if (p / CONFIG_NAME).is_file():
return True
try: # индекс мог быть переименован через docs/.pm.json
try: # индекс мог быть переименован через конфиг
name = load_config(p).get("backlog", DEFAULTS["backlog"])
except Env:
name = DEFAULTS["backlog"]
@@ -1027,7 +1096,10 @@ def check(lay: Layout, fix: bool = False) -> int:
entries = {k: v[0] for k, v in idx.items()}
sections = {k: v[1] for k, v in idx.items()}
tasks = tasks_of(lay)
errors: list[str] = []
# Версия формата идёт первой строкой расхождений: остальные находки читаются
# иначе, когда каталог отстал от формата, — часть из них тогда не дрейф, а
# непройденный шаг журнала.
errors: list[str] = version_problems(lay)
notes: list[str] = []
label = {k: lay.name(k) for k in lay.indexes}
@@ -2524,13 +2596,15 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
cfg: dict) -> dict[Path, str]:
out: dict[Path, str] = {}
if cfg:
# Пишем всегда в свой `.tasks.json`, даже когда рядом живёт
# `docs/.pm.json`: дом настроек принадлежит этому плагину, а `docs/` —
# другому, и его в проекте может не быть. load_config читает свой файл
# первым, так что записанное сюда и прочитается отсюда.
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False,
indent=2) + "\n"
# Файл заводится всегда, даже когда все имена умолчательные: в нём живёт
# версия формата, а версия — не настройка, от которой можно отказаться.
#
# Пишем всегда в свой `.tasks.json`, даже когда рядом живёт `docs/.pm.json`:
# дом настроек принадлежит этому плагину, а `docs/` — другому, и его в
# проекте может не быть. load_config читает свой файл первым, так что
# записанное сюда и прочитается отсюда.
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False,
indent=2) + "\n"
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
@@ -2589,9 +2663,15 @@ def uniq_sections(raw: str) -> list[str]:
def cmd_init(root: Path, a: argparse.Namespace) -> int:
if not dir_within_cwd(root):
raise Usage(f"--dir вне рабочего каталога: {root}")
cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap),
("rejected", a.rejected)) if v}
lay = Layout(root, cfg)
names = {k: v for k, v in (("items", a.items), ("backlog", a.backlog),
("roadmap", a.roadmap), ("rejected", a.rejected)) if v}
# Версия формата — первым ключом и всегда: каталог, заведённый сегодня,
# приведён к сегодняшнему формату, и объявить это должен тот, кто его завёл.
# Имена частей — следом и только те, что названы явно: умолчание, записанное
# в файл, стало бы вторым домом для того же имени. В раскладку версия не
# идёт — `Layout` про имена, и число среди имён там ничего не значит.
cfg = {VERSION_KEY: FORMAT_VERSION, **names}
lay = Layout(root, names)
if lay.index("backlog").exists():
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
@@ -2615,8 +2695,9 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
print(f"каталог задач заведён: {root}")
print(f" секции беклога: {', '.join(sections)};"
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
if cfg:
print(f" имена частей записаны в {root / CONFIG_NAME}")
what = ("версия формата и имена частей записаны" if names
else "версия формата записана")
print(f" {what} в {root / CONFIG_NAME}: формат {FORMAT_VERSION}")
return EXIT_OK
@@ -2974,7 +3055,11 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
# --- план записи ---
wr = Plan()
for path, text in init_files(lay, sections, roadmap_sections, {}).items():
# Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и
# сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен.
# Имён частей здесь нет — адаптация раскладывает всё по умолчаниям.
for path, text in init_files(lay, sections, roadmap_sections,
{VERSION_KEY: FORMAT_VERSION}).items():
wr.file(path, text)
backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines()
roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines()