скрипты: запись настроек перестала уходить мимо и молчать
Найдено ревью, каждое воспроизведено на фикстуре. Граница репозитория. 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:
@@ -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)};"
|
||||
|
||||
Reference in New Issue
Block a user