From 423f9798ef50a0b419b5a10335ac79817bef624a Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 13 Aug 2026 10:56:49 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B:=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=BC=D0=BE=20=D0=B8=20=D0=BC=D0=BE=D0=BB=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено ревью, каждое воспроизведено на фикстуре. Граница репозитория. 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. --- av-dev/shared/config.py | 157 ++++++++++++++---- .../skills/code-openspec/scripts/openspec.py | 10 +- av-dev/skills/doc-canon/scripts/docs.py | 107 +++++++++--- av-dev/skills/task-track/scripts/tasks.py | 128 +++++++++++--- 4 files changed, 314 insertions(+), 88 deletions(-) diff --git a/av-dev/shared/config.py b/av-dev/shared/config.py index b77c912..d0113b2 100644 --- a/av-dev/shared/config.py +++ b/av-dev/shared/config.py @@ -71,14 +71,19 @@ def find_root(start: Path | None = None) -> Path | None: Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам решает, отказ это или неприменимость. + + **Подъём останавливается на первом `.git`, и это не деталь.** Репозиторий + внутри репозитория — обычное дело, и без границы конфиг соседа выигрывал бы + у собственного: вложенный проект объявлялся бы здоровым по чужому файлу, а + запись настроек уходила бы в чужой репозиторий. Свой файл ищется **до** + границы включительно, чужой не ищется вовсе. """ here = (start or Path.cwd()).resolve() for base in (here, *here.parents): if (base / CONFIG_NAME).is_file(): return base - for base in (here, *here.parents): if (base / ".git").exists(): - return base + return base # корень репозитория есть, настроек в нём нет return None @@ -88,15 +93,40 @@ def read(root: Path) -> dict: if not path.is_file(): return {} try: - data = tomllib.loads(path.read_text(encoding="utf-8")) + # Читаем байтами: `tomllib.load` сам знает про кодировку TOML, а + # `read_text` на файле не в UTF-8 роняет UnicodeDecodeError — ошибку + # окружения, которая ушла бы наружу внутренним сбоем. + with path.open("rb") as fh: + data = tomllib.load(fh) except tomllib.TOMLDecodeError as exc: raise ConfigError(f"{CONFIG_NAME} не разбирается как TOML: {exc}") from exc - except OSError as exc: + except (OSError, ValueError) as exc: raise ConfigError(f"{CONFIG_NAME} не читается: {exc}") from exc _validate(data) return data +# Ключи верхнего уровня. Секции знают свои ключи сами: `[docs]` проверяет +# `docs.py`, `[tasks]` — `tasks.py`. Здесь только то, что образует сам файл. +TOP_KEYS = (VERSION_KEY, "docs", "tasks") + + +def check_keys(data: dict, known: tuple[str, ...], where: str) -> None: + """Неизвестный ключ — отказ, а не безмолвный пропуск. + + Ключ, положенный не туда (`migrations` верхним уровнем вместо `[docs]` — + ровно так он лежал в прежнем `.docs.json`, и ровно так его перенесут руками), + иначе не значит ничего: проверка объявляет себя неприменимой, отчёт выходит + зелёным, и на месте настройки оказывается тишина. + """ + unknown = sorted(set(data) - set(known)) + if unknown: + raise ConfigError( + f"{CONFIG_NAME}: неизвестные ключи {where}: {', '.join(unknown)}" + f" (известны: {', '.join(known)})" + ) + + def _validate(data: dict) -> None: got = data.get(VERSION_KEY) if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)): @@ -105,12 +135,13 @@ def _validate(data: dict) -> None: f" ожидалось целое число, а не {got!r}" ) for name in ("docs", "tasks"): - section = data.get(name) - if section is not None and not isinstance(section, dict): + got_section = data.get(name) + if got_section is not None and not isinstance(got_section, dict): raise ConfigError( f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек," - f" а не {section!r}" + f" а не {got_section!r}" ) + check_keys(data, TOP_KEYS, "верхнего уровня") def section(cfg: dict, name: str) -> dict: @@ -144,21 +175,70 @@ def legacy_files(root: Path, tasks_dir: Path | None = None) -> list[str]: return found +def quote(value: str) -> str: + """Значение как строка TOML: экранирование, а не конкатенация в кавычки. + + Без него имя файла с кавычкой или путь с обратной косой чертой ломают + **весь** файл: `tomllib` отказывается разбирать его целиком, и оба скрипта + после этого отвечают кодом 3 на любую команду. Пишет сюда машина, а + последствия достаются человеку, который такого имени не выбирал. + """ + out = value.replace("\\", "\\\\").replace('"', '\\"') + out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + return f'"{out}"' + + +def _strip_comment(line: str) -> str: + """Строка без хвостового комментария. Кавычки уважаются: `#` внутри них — текст.""" + quoted = False + for i, ch in enumerate(line): + if ch == '"' and (i == 0 or line[i - 1] != "\\"): + quoted = not quoted + elif ch == "#" and not quoted: + return line[:i] + return line + + +def _is_header(line: str, name: str | None = None) -> bool: + """Заголовок секции — по разбору, а не по совпадению строки. + + `[tasks] # имена частей` — законный TOML и ровно та возможность, ради + которой формат и взят. Сравнение строк её не узнаёт, дописывает вторую + таблицу с тем же именем, и `tomllib` отвергает файл целиком. + """ + body = _strip_comment(line).strip() + if not (body.startswith("[") and body.endswith("]")): + return False + return name is None or body[1:-1].strip() == name + + def set_version(root: Path, number: int) -> None: """Двинуть версию, не тронув остального: правится одна строка. Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего - формат и выбран. Ключа нет вовсе — строка встаёт первой, до всякой секции: - ключ верхнего уровня, попавший под `[docs]`, читался бы как её настройка. + формат и выбран. + + **Ищется только ключ верхнего уровня** — то есть выше первого заголовка + секции. `version` внутри `[docs]` принадлежит проекту и значит что угодно + своё; двинув его, мы объявили бы приведённым не то, о чём речь, и оставили + бы настоящую версию неназванной. Ключа нет вовсе — строка встаёт первой, до + всякой секции, по той же причине. """ path = root / CONFIG_NAME - text = path.read_text(encoding="utf-8") if path.is_file() else "" - pattern = re.compile(rf"(?m)^(\s*{VERSION_KEY}\s*=\s*)(\d+)(.*)$") - if pattern.search(text): - text = pattern.sub(rf"\g<1>{number}\g<3>", text, count=1) + lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else [] + end = next((i for i, ln in enumerate(lines) if _is_header(ln)), len(lines)) + # Значение берётся до комментария и может быть каким угодно — в том числе + # строкой в кавычках: файл правят руками. Заменяется оно целиком, иначе + # рядом появился бы второй ключ `version`, и файл перестал бы разбираться. + pattern = re.compile(rf"^(\s*{VERSION_KEY}\s*=\s*)([^#]*?)(\s*(?:#.*)?)$") + for i in range(end): + match = pattern.match(lines[i]) + if match: + lines[i] = f"{match.group(1)}{number}{match.group(3)}" + break else: - text = f"{VERSION_KEY} = {number}\n" + text - path.write_text(text, encoding="utf-8") + lines.insert(0, f"{VERSION_KEY} = {number}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") def merge_section(root: Path, name: str, values: dict) -> list[str]: @@ -167,35 +247,50 @@ def merge_section(root: Path, name: str, values: dict) -> list[str]: Правка построчная по той же причине, что и у версии: перезапись файла целиком стёрла бы комментарии. Ключ, который в секции уже есть, не трогается вовсе — файл в чужом репозитории правит человек, и затирать его значение - своим умолчанием нельзя. + своим умолчанием нельзя. **Что дописано, а что нет, решает зовущий:** список + возвращается, и молчать о неписаном ему нельзя. """ path = root / CONFIG_NAME lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else [] - header = f"[{name}]" - start = next((i for i, ln in enumerate(lines) if ln.strip() == header), None) + start = next((i for i, ln in enumerate(lines) if _is_header(ln, name)), None) if start is None: - added = [f'{k} = "{v}"' for k, v in values.items()] - if not added: + if not values: return [] - block = ([""] if lines and lines[-1].strip() else []) + [header, *added] + block = ([""] if lines and lines[-1].strip() else []) + [f"[{name}]"] + block += [f"{k} = {quote(v)}" for k, v in values.items()] path.write_text("\n".join([*lines, *block]) + "\n", encoding="utf-8") return list(values) - end = next((i for i in range(start + 1, len(lines)) - if lines[i].lstrip().startswith("[")), len(lines)) + end = next((i for i in range(start + 1, len(lines)) if _is_header(lines[i])), + len(lines)) body = lines[start + 1:end] - have = {ln.split("=", 1)[0].strip() for ln in body if "=" in ln and not - ln.lstrip().startswith("#")} + have = {ln.split("=", 1)[0].strip() for ln in map(_strip_comment, body) + if "=" in ln} added = [k for k in values if k not in have] if not added: return [] - insert = [f'{k} = "{values[k]}"' for k in added] + # Пустые строки в хвосте секции — отбивка перед следующим заголовком. + # Дописываем до неё, а её возвращаем на место: иначе файл слипается. + trailing = 0 while body and not body[-1].strip(): body.pop() - lines[start + 1:end] = [*body, *insert] + trailing += 1 + insert = [f"{k} = {quote(values[k])}" for k in added] + lines[start + 1:end] = [*body, *insert, *([""] * trailing)] path.write_text("\n".join(lines) + "\n", encoding="utf-8") return added +def missing_keys(root: Path, name: str, values: dict) -> dict: + """Ключи секции, которые уже есть и разошлись с тем, что мы собирались дать. + + `merge_section` чужого значения не трогает — и правильно делает, — но + промолчать о расхождении нельзя: `dir` из настроек и `--dir` из вызова, + разойдясь, оставляют каталог, до которого потом не дотянется никто. + """ + have = section(read(root), name) + return {k: have[k] for k, v in values.items() if k in have and have[k] != v} + + def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -> str: """Свежий файл с комментариями — тем, ради чего взят TOML. @@ -215,14 +310,14 @@ def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) - if docs.get("migrations"): out += [ "# каталог миграций: по нему docs.py сверяет схему с database.md", - f'migrations = "{docs["migrations"]}"', + f"migrations = {quote(docs['migrations'])}", ] else: - out += ["# migrations = \"путь/к/миграциям\" — появится, когда появится БД"] + out += ['# migrations = "путь/к/миграциям" — появится, когда появится БД'] out += ["", "[tasks]", "# каталог задач от корня репозитория; имена частей — умолчания скрипта", - f'dir = "{tasks.get("dir", "tasks")}"'] + f"dir = {quote(tasks.get('dir', 'tasks'))}"] for key in ("items", "backlog", "roadmap"): if tasks.get(key): - out.append(f'{key} = "{tasks[key]}"') + out.append(f"{key} = {quote(tasks[key])}") return "\n".join(out) + "\n" diff --git a/av-dev/skills/code-openspec/scripts/openspec.py b/av-dev/skills/code-openspec/scripts/openspec.py index 8b7f95d..1a1353a 100644 --- a/av-dev/skills/code-openspec/scripts/openspec.py +++ b/av-dev/skills/code-openspec/scripts/openspec.py @@ -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)} пунктов.") diff --git a/av-dev/skills/doc-canon/scripts/docs.py b/av-dev/skills/doc-canon/scripts/docs.py index cd74098..73e66ae 100644 --- a/av-dev/skills/doc-canon/scripts/docs.py +++ b/av-dev/skills/doc-canon/scripts/docs.py @@ -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) diff --git a/av-dev/skills/task-track/scripts/tasks.py b/av-dev/skills/task-track/scripts/tasks.py index 8ac9239..33ad2df 100755 --- a/av-dev/skills/task-track/scripts/tasks.py +++ b/av-dev/skills/task-track/scripts/tasks.py @@ -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']}/.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)};"