скрипты: запись настроек перестала уходить мимо и молчать

Найдено ревью, каждое воспроизведено на фикстуре.

Граница репозитория. find_root обходил всех предков в поисках .av-dev.toml и
только потом смотрел на .git: вложенный проект объявлялся здоровым по конфигу
соседа, а init дописывал секцию в чужой репозиторий. Подъём останавливается на
первом .git. Туда же ключ [tasks] dir: он не судился как путь, и «../соседний»
уводил запись за пределы репозитория с зелёным кодом.

Запись TOML. Значения склеивались в кавычки без экранирования — имя индекса с
кавычкой ломало файл целиком, и оба скрипта после этого отвечали кодом 3 на
любую команду. Заголовок секции искался точным сравнением строки, так что
«[tasks]  # комментарий» — ровно та возможность, ради которой взят формат, — не
находился, и дописывалась вторая таблица. set_version правил version внутри
секции и ломал файл, если версия в кавычках.

Молчание вместо отказа. Неизвестные ключи не отвергались: migrations, положенный
верхним уровнем (а именно так его перенесут руками из .docs.json), давал
«проверка неприменима» и зелёный итог. Неверный dir проваливался в поиск вверх, и
скрипт работал в другом каталоге, не сказав о ключе. Прежние файлы узнавались
только когда нового нет — половина переезда проходила молча.

Честность доклада. Версию не двигал никто: set_version был написан и не подключён,
init печатал версию, которой не записал, adopt писал dir мимо --target. Теперь
повышение — команда docs.py bump, а init и adopt зовут общий write_config, который
пишет версию, дописывает ключи и вслух называет разошедшиеся. Отсутствие
shared/config.py давало трейсбек и код 1 вместо 3. Число зовётся LAYOUT_VERSION в
обоих скриптах вместо CANON_VERSION и FORMAT_VERSION.
This commit is contained in:
av
2026-08-13 10:56:49 +03:00
parent 7d559e60ec
commit 423f9798ef
4 changed files with 314 additions and 88 deletions
@@ -4,7 +4,7 @@
Каталог `openspec/` — предпосылка **конвейера**, а не канона документов: без него
не работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Поэтому и
проверка формы живёт здесь, рядом со скиллом, который каталог заводит. Раньше она
жила в `docs.py` плагина канона, и у файла было два владельца: один заводит,
жила в `docs.py`, у скилла канона, и у файла было два владельца: один заводит,
другой проверяет.
Проверяется то, что **молчит при поломке**. Файл из коробки хуже отсутствующего:
@@ -219,8 +219,8 @@ def check_form(root: Path, rep: Report) -> None:
if not (root / where).exists():
rep.skip(
f"{where} в проекте нет — ссылка на него в context не "
f"требуется. Документы канона ведёт отдельный плагин "
f"(av-dev-docs), и без него конвейер работает вслепую"
f"требуется. Документы канона проект не завёл, и без них "
f"конвейер работает вслепую: заводит их av-dev:doc-canon"
)
continue
if pointer not in live:
@@ -289,8 +289,8 @@ def report(rep: Report) -> int:
"документов проекта и ключи rules против артефактов схемы. Чего она не\n"
"видит — **пересказ вместо ссылки**: утверждение, которое можно\n"
"опровергнуть, открыв другой файл проекта, от строки «открой такой-то\n"
"файл» она не отличает. Это суждение агента `doc-consistency` из плагина\n"
"канона документов; нет плагина — нет и этой проверки, и так и скажи."
"файл» она не отличает. Это суждение агента `doc-consistency`; документов\n"
"канона в проекте нет — сверять пересказ не с чем, и так и скажи."
)
if rep.errors:
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
+82 -25
View File
@@ -37,8 +37,11 @@ def _load_shared() -> ModuleType:
Своё дерево — единственное, куда ходить можно; в чужое не ходим никогда.
"""
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 spec is None or spec.loader is None:
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)
@@ -51,7 +54,7 @@ conf = _load_shared()
# Версия раскладки одна на плагин и живёт в `shared/config.py`: её знают оба
# скрипта, и второе число здесь было бы вторым домом.
CANON_VERSION = conf.VERSION
LAYOUT_VERSION = conf.VERSION
# Дом версии и путей, нужных проверкам, — `.av-dev.toml` в корне репозитория.
# До слияния плагинов файлов было два, `docs/.docs.json` и `.tasks.json`, и
@@ -288,9 +291,16 @@ def fail(code: int, msg: str) -> NoReturn:
def read_config(root: Path, rep: Report) -> dict:
"""Настройки проекта целиком; проверкам канона нужна секция `[docs]`."""
try:
return conf.read(root)
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:
@@ -307,15 +317,15 @@ def check_version(root: Path, cfg: dict, rep: Report) -> None:
if got is None:
rep.error(f"в {CONFIG} нет ключа version — версия раскладки не объявлена")
return
if got < CANON_VERSION:
if got < LAYOUT_VERSION:
rep.error(
f"проект приведён к раскладке версии {got}, текущая — {CANON_VERSION}: "
f"нужен canon upgrade"
f"проект приведён к раскладке версии {got}, текущая — {LAYOUT_VERSION}:"
f" нужно повышение (скилл av-dev:doc-canon, операция upgrade)"
)
elif got > CANON_VERSION:
elif got > LAYOUT_VERSION:
rep.error(
f"проект приведён к раскладке версии {got}, а скрипт знает"
f" {CANON_VERSION}: устарел плагин, обнови маркетплейс"
f" {LAYOUT_VERSION}: устарел плагин, обнови маркетплейс"
)
@@ -345,24 +355,40 @@ def doc_home(root: Path, name: str) -> tuple[Path | None, str | 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
# Настройки под прежними именами — это не «нет файла», а незаконченный
# переезд. Без этой ветки проект слышал бы «нет версии» и шёл заводить
# второй файл рядом с первым, а старые остались бы вторым домом.
legacy = conf.legacy_files(root)
if rel == CONFIG and legacy:
rep.error(
f"нет {rel}{what}. Настройки лежат по прежней раскладке"
f" ({', '.join(legacy)}) — она осталась от трёх плагинов, которые"
f" слились в один: перенеси значения и удали старые файлы"
f" операцией upgrade скилла av-dev:doc-canon (журнал, версия 1)."
f" Прежние имена не читаются, поэтому в этом прогоне всё"
f" остальное проверено так, будто настроек нет вовсе"
)
continue
if rel == CONFIG and conf.legacy_files(root):
continue # об этом уже сказала check_legacy, и подробнее
rep.error(f"нет {rel}{what}")
for name, (kind, what) in DOCS.items():
@@ -640,6 +666,7 @@ def cmd_check(args: argparse.Namespace) -> int:
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)
@@ -652,10 +679,36 @@ def cmd_check(args: argparse.Namespace) -> int:
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"раскладка скрипта: {CANON_VERSION}")
print(f"раскладка проекта: {got if got is not None else 'не объявлена'}")
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
@@ -671,10 +724,14 @@ def main() -> int:
p_check.add_argument("--base", default=None, help="база диффа для сверки миграций")
p_check.set_defaults(func=cmd_check)
p_ver = sub.add_parser("version", help="версия канона скрипта и проекта")
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)
+101 -27
View File
@@ -118,8 +118,11 @@ def _load_shared() -> ModuleType:
дерева плагина в текущем каталоге нет.
"""
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 spec is None or spec.loader is None:
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(3)
@@ -141,7 +144,7 @@ CONFIG_NAME = conf.CONFIG_NAME # дом настроек и версии: `.a
# Переезды каталога, случившиеся до слияния (в корень, отмена спринтов), задним
# числом в журнал не переписаны: они названы прежними журналами, и второй
# перечень тех же шагов разошёлся бы с первым.
FORMAT_VERSION = conf.VERSION
LAYOUT_VERSION = conf.VERSION
VERSION_KEY = conf.VERSION_KEY
EXIT_OK = 0
@@ -517,6 +520,13 @@ def _validate_config(data: dict, path: Path) -> dict:
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
raise Env(f"{path}: ключ «{key}» = «{value}» — только имя внутри каталога задач")
# `dir` судится строже прочих: он указывает каталог, а не имя внутри
# него, и без этой проверки «../соседний» уводит запись за пределы
# репозитория молча — с зелёным кодом и путём, который в докладе
# выглядит своим.
if key == DIR_KEY and (Path(value).is_absolute() or ".." in Path(value).parts):
raise Env(f"{path}: ключ «{DIR_KEY}» = «{value}» — только путь внутри"
f" репозитория, без «..» и без корня")
return data
@@ -563,29 +573,37 @@ def version_problems(lay: Layout) -> list[str]:
Заводит число `init`, двигает — операция `upgrade` скилла.
"""
path = lay.project / CONFIG_NAME
legacy = conf.legacy_files(lay.project, lay.root)
out = []
# Прежние файлы называются всегда, а не только когда нового нет: половина
# переезда — заведён новый, старые остались — иначе проходит молча, и второй
# дом для той же версии живёт дальше.
if legacy and path.is_file():
out.append(f"прежняя раскладка не убрана: {', '.join(legacy)} рядом с"
f" {CONFIG_NAME}. Эти файлы не читаются, а версия в них своя —"
f" удали их: переезд не закончен (журнал, версия 1, шаг 3)")
if not path.is_file():
legacy = conf.legacy_files(lay.project, lay.root)
if legacy:
return [f"нет {path}, а прежняя раскладка на месте"
f" ({', '.join(legacy)}): перенеси настройки и удали старые"
f" файлы операцией upgrade скилла av-dev:doc-canon"]
return [f"нет {path} — версия раскладки не объявлена."
f" Заведи файл с «{VERSION_KEY} = {FORMAT_VERSION}» (журнал"
f" Заведи файл с «{VERSION_KEY} = {LAYOUT_VERSION}» (журнал"
f" версий — references/changelog.md скилла av-dev:doc-canon)"]
# Что число целое, уже проверил общий читатель — иначе сюда не дошли бы
# вовсе (код 3). Здесь `isinstance` значит ровно «ключ есть».
got = conf.version(lay.full)
if got is None:
return [f"{path}: нет ключа «{VERSION_KEY}» — версия раскладки не"
f" объявлена, текущая {FORMAT_VERSION}"]
if got < FORMAT_VERSION:
return [f"проект приведён к раскладке версии {got}, текущая —"
f" {FORMAT_VERSION}: нужно повышение по журналу"
f" (скилл av-dev:doc-canon, операция upgrade)"]
if got > FORMAT_VERSION:
return [f"проект приведён к раскладке версии {got}, а скрипт знает"
f" {FORMAT_VERSION}: устарел плагин, обнови маркетплейс"]
return []
out.append(f"{path}: нет ключа «{VERSION_KEY}» — версия раскладки не"
f" объявлена, текущая {LAYOUT_VERSION}")
elif got < LAYOUT_VERSION:
out.append(f"проект приведён к раскладке версии {got}, текущая —"
f" {LAYOUT_VERSION}: нужно повышение по журналу"
f" (скилл av-dev:doc-canon, операция upgrade)")
elif got > LAYOUT_VERSION:
out.append(f"проект приведён к раскладке версии {got}, а скрипт знает"
f" {LAYOUT_VERSION}: устарел плагин, обнови маркетплейс")
return out
def looks_like_tasks(p: Path, names: dict | None = None) -> bool:
@@ -625,8 +643,20 @@ def resolve_layout(explicit: str | None) -> Layout:
f" новый проект — tasks.py init --dir {explicit}")
return Layout(root, names, project or root.resolve(), full)
if DIR_KEY in names:
# Ключ назван — значит ответ на «где каталог» уже дан. Не нашли по нему
# — это отказ, а не повод искать дальше: молчаливый уход на умолчание
# означал бы работу в другом каталоге, о котором никто не просил.
candidate = (project or here) / names[DIR_KEY]
if not looks_like_tasks(candidate, names):
raise Env(f"каталог задач не найден по ключу [tasks] {DIR_KEY} ="
f" «{names[DIR_KEY]}» → {candidate}: индекса"
f" {names.get('backlog') or DEFAULTS['backlog']} там нет."
f" Поправь ключ в {CONFIG_NAME} или заведи каталог")
return Layout(relative_if_inside(candidate, here), names, project, full)
if project:
candidate = project / names.get(DIR_KEY, DEFAULT_DIR)
candidate = project / DEFAULT_DIR
if looks_like_tasks(candidate, names):
return Layout(relative_if_inside(candidate, here), names, project, full)
@@ -2601,14 +2631,10 @@ 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] = {}
# Служебный файл заводится всегда, даже когда все имена умолчательные: в нём
# живёт версия раскладки, а версия — не настройка, от которой можно
# отказаться. Файл уже есть (проект под каноном, заводят только задачи) —
# он не перезаписывается: комментарии в нём принадлежат человеку. Тогда
# недостающие ключи секции дописываются построчно, и делает это `cmd_init`
# после записи файлов, потому что правка идёт по живому файлу, а не планом.
if not (lay.project / CONFIG_NAME).is_file():
out[lay.project / CONFIG_NAME] = conf.skeleton(FORMAT_VERSION, tasks=cfg)
# Служебный файл здесь не заводится: его пишет `write_config` по живому
# файлу — версию двигает построчно, ключи дописывает, чужого не затирает.
# Планом это сделать нельзя, потому что план перезаписывает целиком, а
# перезапись стёрла бы комментарии — то, ради чего взят TOML.
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
@@ -2664,6 +2690,49 @@ def uniq_sections(raw: str) -> list[str]:
return out
def adopt_cfg(lay: Layout) -> dict:
"""Что адаптация обязана записать о себе: где встал каталог.
Имён частей здесь нет — адаптация раскладывает всё по умолчаниям, — а путь
есть всегда, даже умолчательный: `--target` задаёт его свободно, и молча
записанное «tasks» указывало бы в пустоту.
"""
try:
rel = lay.root.resolve().relative_to(lay.project.resolve()).as_posix()
except ValueError:
return {}
return {DIR_KEY: rel}
def write_config(project: Path, cfg: dict) -> list[str]:
"""Записать версию и настройки каталога; вернуть строки доклада.
Файла нет — он заводится целиком скелетом, с комментариями. Файл есть — в
нём двигается версия и дописываются недостающие ключи секции; чужое
значение не затирается, но и не замалчивается: разошедшийся ключ уезжает в
доклад строкой, потому что `dir`, указывающий не туда, куда только что
заведён каталог, оставляет каталог недостижимым.
"""
path = project / CONFIG_NAME
if not path.is_file():
path.write_text(conf.skeleton(LAYOUT_VERSION, tasks=cfg), encoding="utf-8")
return [f"{CONFIG_NAME} заведён: версия {LAYOUT_VERSION}"
f"{', ' + ', '.join(sorted(cfg)) if cfg else ''}"]
out = []
if conf.version(conf.read(project)) != LAYOUT_VERSION:
conf.set_version(project, LAYOUT_VERSION)
out.append(f"версия раскладки в {CONFIG_NAME}: {LAYOUT_VERSION}")
clash = conf.missing_keys(project, "tasks", cfg)
added = conf.merge_section(project, "tasks", cfg)
if added:
out.append(f"дописано в [tasks]: {', '.join(added)}")
for key, had in sorted(clash.items()):
out.append(f"ВНИМАНИЕ [tasks] {key} = «{had}» оставлен как был, а каталог"
f" заведён под «{cfg[key]}» — поправь {CONFIG_NAME} руками,"
f" иначе скрипт пойдёт не туда")
return out or [f"{CONFIG_NAME} уже описывает эту раскладку"]
def cmd_init(root: Path, a: argparse.Namespace) -> int:
if not dir_within_cwd(root):
raise Usage(f"--dir вне рабочего каталога: {root}")
@@ -2706,13 +2775,12 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
for path, text in init_files(lay, sections, roadmap_sections, cfg).items():
plan.file(path, text)
plan.commit()
added = conf.merge_section(project, "tasks", cfg) if cfg else []
said = write_config(project, cfg)
print(f"каталог задач заведён: {root}")
print(f" секции беклога: {', '.join(sections)};"
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
if added:
print(f" дописано в [tasks] {project / CONFIG_NAME}: {', '.join(added)}")
print(f" версия раскладки в {project / CONFIG_NAME}: {FORMAT_VERSION}")
for line in said:
print(f" {line}")
return EXIT_OK
@@ -3143,6 +3211,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
return EXIT_OK
lay.items.mkdir(parents=True, exist_ok=True)
wr.commit()
# Настройки — тем же проходом, что и у `init`, и по той же причине: каталог,
# собранный здесь, обязан быть назван в `.av-dev.toml`, иначе следующая же
# команда не найдёт его и уйдёт искать умолчание.
said = write_config(lay.project, adopt_cfg(lay))
# --- перекрёстные ссылки: тем же проходом, иначе они останутся битыми ---
ref_paths: list[Path] = [*lay.items.glob("*.md"), lay.index("rejected")]
@@ -3153,6 +3225,8 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
[tuple(pair) for pair in pl.get("path_map", [])], False)
print(f"каталог задач собран: {root}")
for line in said:
print(f" {line}")
print(f" целей {len(pl.get('goals', []))}, задач {len(pl.get('items', []))},"
f" строк кладбища {len(pl.get('rejected', []))}")
print(f" переименовано слагов: {len(renames)};"