скрипты: запись настроек перестала уходить мимо и молчать
Найдено ревью, каждое воспроизведено на фикстуре. Граница репозитория. 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:
+126
-31
@@ -71,14 +71,19 @@ def find_root(start: Path | None = None) -> Path | None:
|
|||||||
Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже
|
Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже
|
||||||
можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам
|
можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам
|
||||||
решает, отказ это или неприменимость.
|
решает, отказ это или неприменимость.
|
||||||
|
|
||||||
|
**Подъём останавливается на первом `.git`, и это не деталь.** Репозиторий
|
||||||
|
внутри репозитория — обычное дело, и без границы конфиг соседа выигрывал бы
|
||||||
|
у собственного: вложенный проект объявлялся бы здоровым по чужому файлу, а
|
||||||
|
запись настроек уходила бы в чужой репозиторий. Свой файл ищется **до**
|
||||||
|
границы включительно, чужой не ищется вовсе.
|
||||||
"""
|
"""
|
||||||
here = (start or Path.cwd()).resolve()
|
here = (start or Path.cwd()).resolve()
|
||||||
for base in (here, *here.parents):
|
for base in (here, *here.parents):
|
||||||
if (base / CONFIG_NAME).is_file():
|
if (base / CONFIG_NAME).is_file():
|
||||||
return base
|
return base
|
||||||
for base in (here, *here.parents):
|
|
||||||
if (base / ".git").exists():
|
if (base / ".git").exists():
|
||||||
return base
|
return base # корень репозитория есть, настроек в нём нет
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -88,15 +93,40 @@ def read(root: Path) -> dict:
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return {}
|
return {}
|
||||||
try:
|
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:
|
except tomllib.TOMLDecodeError as exc:
|
||||||
raise ConfigError(f"{CONFIG_NAME} не разбирается как TOML: {exc}") from 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
|
raise ConfigError(f"{CONFIG_NAME} не читается: {exc}") from exc
|
||||||
_validate(data)
|
_validate(data)
|
||||||
return 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:
|
def _validate(data: dict) -> None:
|
||||||
got = data.get(VERSION_KEY)
|
got = data.get(VERSION_KEY)
|
||||||
if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)):
|
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}"
|
f" ожидалось целое число, а не {got!r}"
|
||||||
)
|
)
|
||||||
for name in ("docs", "tasks"):
|
for name in ("docs", "tasks"):
|
||||||
section = data.get(name)
|
got_section = data.get(name)
|
||||||
if section is not None and not isinstance(section, dict):
|
if got_section is not None and not isinstance(got_section, dict):
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек,"
|
f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек,"
|
||||||
f" а не {section!r}"
|
f" а не {got_section!r}"
|
||||||
)
|
)
|
||||||
|
check_keys(data, TOP_KEYS, "верхнего уровня")
|
||||||
|
|
||||||
|
|
||||||
def section(cfg: dict, name: str) -> dict:
|
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
|
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:
|
def set_version(root: Path, number: int) -> None:
|
||||||
"""Двинуть версию, не тронув остального: правится одна строка.
|
"""Двинуть версию, не тронув остального: правится одна строка.
|
||||||
|
|
||||||
Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего
|
Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего
|
||||||
формат и выбран. Ключа нет вовсе — строка встаёт первой, до всякой секции:
|
формат и выбран.
|
||||||
ключ верхнего уровня, попавший под `[docs]`, читался бы как её настройка.
|
|
||||||
|
**Ищется только ключ верхнего уровня** — то есть выше первого заголовка
|
||||||
|
секции. `version` внутри `[docs]` принадлежит проекту и значит что угодно
|
||||||
|
своё; двинув его, мы объявили бы приведённым не то, о чём речь, и оставили
|
||||||
|
бы настоящую версию неназванной. Ключа нет вовсе — строка встаёт первой, до
|
||||||
|
всякой секции, по той же причине.
|
||||||
"""
|
"""
|
||||||
path = root / CONFIG_NAME
|
path = root / CONFIG_NAME
|
||||||
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
||||||
pattern = re.compile(rf"(?m)^(\s*{VERSION_KEY}\s*=\s*)(\d+)(.*)$")
|
end = next((i for i, ln in enumerate(lines) if _is_header(ln)), len(lines))
|
||||||
if pattern.search(text):
|
# Значение берётся до комментария и может быть каким угодно — в том числе
|
||||||
text = pattern.sub(rf"\g<1>{number}\g<3>", text, count=1)
|
# строкой в кавычках: файл правят руками. Заменяется оно целиком, иначе
|
||||||
|
# рядом появился бы второй ключ `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:
|
else:
|
||||||
text = f"{VERSION_KEY} = {number}\n" + text
|
lines.insert(0, f"{VERSION_KEY} = {number}")
|
||||||
path.write_text(text, encoding="utf-8")
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def merge_section(root: Path, name: str, values: dict) -> list[str]:
|
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
|
path = root / CONFIG_NAME
|
||||||
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
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 _is_header(ln, name)), None)
|
||||||
start = next((i for i, ln in enumerate(lines) if ln.strip() == header), None)
|
|
||||||
if start is None:
|
if start is None:
|
||||||
added = [f'{k} = "{v}"' for k, v in values.items()]
|
if not values:
|
||||||
if not added:
|
|
||||||
return []
|
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")
|
path.write_text("\n".join([*lines, *block]) + "\n", encoding="utf-8")
|
||||||
return list(values)
|
return list(values)
|
||||||
end = next((i for i in range(start + 1, len(lines))
|
end = next((i for i in range(start + 1, len(lines)) if _is_header(lines[i])),
|
||||||
if lines[i].lstrip().startswith("[")), len(lines))
|
len(lines))
|
||||||
body = lines[start + 1:end]
|
body = lines[start + 1:end]
|
||||||
have = {ln.split("=", 1)[0].strip() for ln in body if "=" in ln and not
|
have = {ln.split("=", 1)[0].strip() for ln in map(_strip_comment, body)
|
||||||
ln.lstrip().startswith("#")}
|
if "=" in ln}
|
||||||
added = [k for k in values if k not in have]
|
added = [k for k in values if k not in have]
|
||||||
if not added:
|
if not added:
|
||||||
return []
|
return []
|
||||||
insert = [f'{k} = "{values[k]}"' for k in added]
|
# Пустые строки в хвосте секции — отбивка перед следующим заголовком.
|
||||||
|
# Дописываем до неё, а её возвращаем на место: иначе файл слипается.
|
||||||
|
trailing = 0
|
||||||
while body and not body[-1].strip():
|
while body and not body[-1].strip():
|
||||||
body.pop()
|
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")
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return added
|
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:
|
def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -> str:
|
||||||
"""Свежий файл с комментариями — тем, ради чего взят TOML.
|
"""Свежий файл с комментариями — тем, ради чего взят TOML.
|
||||||
|
|
||||||
@@ -215,14 +310,14 @@ def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -
|
|||||||
if docs.get("migrations"):
|
if docs.get("migrations"):
|
||||||
out += [
|
out += [
|
||||||
"# каталог миграций: по нему docs.py сверяет схему с database.md",
|
"# каталог миграций: по нему docs.py сверяет схему с database.md",
|
||||||
f'migrations = "{docs["migrations"]}"',
|
f"migrations = {quote(docs['migrations'])}",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
out += ["# migrations = \"путь/к/миграциям\" — появится, когда появится БД"]
|
out += ['# migrations = "путь/к/миграциям" — появится, когда появится БД']
|
||||||
out += ["", "[tasks]",
|
out += ["", "[tasks]",
|
||||||
"# каталог задач от корня репозитория; имена частей — умолчания скрипта",
|
"# каталог задач от корня репозитория; имена частей — умолчания скрипта",
|
||||||
f'dir = "{tasks.get("dir", "tasks")}"']
|
f"dir = {quote(tasks.get('dir', 'tasks'))}"]
|
||||||
for key in ("items", "backlog", "roadmap"):
|
for key in ("items", "backlog", "roadmap"):
|
||||||
if tasks.get(key):
|
if tasks.get(key):
|
||||||
out.append(f'{key} = "{tasks[key]}"')
|
out.append(f"{key} = {quote(tasks[key])}")
|
||||||
return "\n".join(out) + "\n"
|
return "\n".join(out) + "\n"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
Каталог `openspec/` — предпосылка **конвейера**, а не канона документов: без него
|
Каталог `openspec/` — предпосылка **конвейера**, а не канона документов: без него
|
||||||
не работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Поэтому и
|
не работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Поэтому и
|
||||||
проверка формы живёт здесь, рядом со скиллом, который каталог заводит. Раньше она
|
проверка формы живёт здесь, рядом со скиллом, который каталог заводит. Раньше она
|
||||||
жила в `docs.py` плагина канона, и у файла было два владельца: один заводит,
|
жила в `docs.py`, у скилла канона, и у файла было два владельца: один заводит,
|
||||||
другой проверяет.
|
другой проверяет.
|
||||||
|
|
||||||
Проверяется то, что **молчит при поломке**. Файл из коробки хуже отсутствующего:
|
Проверяется то, что **молчит при поломке**. Файл из коробки хуже отсутствующего:
|
||||||
@@ -219,8 +219,8 @@ def check_form(root: Path, rep: Report) -> None:
|
|||||||
if not (root / where).exists():
|
if not (root / where).exists():
|
||||||
rep.skip(
|
rep.skip(
|
||||||
f"{where} в проекте нет — ссылка на него в context не "
|
f"{where} в проекте нет — ссылка на него в context не "
|
||||||
f"требуется. Документы канона ведёт отдельный плагин "
|
f"требуется. Документы канона проект не завёл, и без них "
|
||||||
f"(av-dev-docs), и без него конвейер работает вслепую"
|
f"конвейер работает вслепую: заводит их av-dev:doc-canon"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if pointer not in live:
|
if pointer not in live:
|
||||||
@@ -289,8 +289,8 @@ def report(rep: Report) -> int:
|
|||||||
"документов проекта и ключи rules против артефактов схемы. Чего она не\n"
|
"документов проекта и ключи rules против артефактов схемы. Чего она не\n"
|
||||||
"видит — **пересказ вместо ссылки**: утверждение, которое можно\n"
|
"видит — **пересказ вместо ссылки**: утверждение, которое можно\n"
|
||||||
"опровергнуть, открыв другой файл проекта, от строки «открой такой-то\n"
|
"опровергнуть, открыв другой файл проекта, от строки «открой такой-то\n"
|
||||||
"файл» она не отличает. Это суждение агента `doc-consistency` из плагина\n"
|
"файл» она не отличает. Это суждение агента `doc-consistency`; документов\n"
|
||||||
"канона документов; нет плагина — нет и этой проверки, и так и скажи."
|
"канона в проекте нет — сверять пересказ не с чем, и так и скажи."
|
||||||
)
|
)
|
||||||
if rep.errors:
|
if rep.errors:
|
||||||
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
|
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ def _load_shared() -> ModuleType:
|
|||||||
Своё дерево — единственное, куда ходить можно; в чужое не ходим никогда.
|
Своё дерево — единственное, куда ходить можно; в чужое не ходим никогда.
|
||||||
"""
|
"""
|
||||||
path = Path(__file__).resolve().parents[3] / "shared" / "config.py"
|
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)
|
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} — общий читатель настроек;"
|
print(f"ОТКАЗ: не читается {path} — общий читатель настроек;"
|
||||||
f" переустанови плагин av-dev", file=sys.stderr)
|
f" переустанови плагин av-dev", file=sys.stderr)
|
||||||
sys.exit(ENV)
|
sys.exit(ENV)
|
||||||
@@ -51,7 +54,7 @@ conf = _load_shared()
|
|||||||
|
|
||||||
# Версия раскладки одна на плагин и живёт в `shared/config.py`: её знают оба
|
# Версия раскладки одна на плагин и живёт в `shared/config.py`: её знают оба
|
||||||
# скрипта, и второе число здесь было бы вторым домом.
|
# скрипта, и второе число здесь было бы вторым домом.
|
||||||
CANON_VERSION = conf.VERSION
|
LAYOUT_VERSION = conf.VERSION
|
||||||
|
|
||||||
# Дом версии и путей, нужных проверкам, — `.av-dev.toml` в корне репозитория.
|
# Дом версии и путей, нужных проверкам, — `.av-dev.toml` в корне репозитория.
|
||||||
# До слияния плагинов файлов было два, `docs/.docs.json` и `.tasks.json`, и
|
# До слияния плагинов файлов было два, `docs/.docs.json` и `.tasks.json`, и
|
||||||
@@ -288,9 +291,16 @@ def fail(code: int, msg: str) -> NoReturn:
|
|||||||
def read_config(root: Path, rep: Report) -> dict:
|
def read_config(root: Path, rep: Report) -> dict:
|
||||||
"""Настройки проекта целиком; проверкам канона нужна секция `[docs]`."""
|
"""Настройки проекта целиком; проверкам канона нужна секция `[docs]`."""
|
||||||
try:
|
try:
|
||||||
return conf.read(root)
|
cfg = conf.read(root)
|
||||||
|
conf.check_keys(docs_cfg(cfg), DOCS_KEYS, "в секции [docs]")
|
||||||
except conf.ConfigError as exc:
|
except conf.ConfigError as exc:
|
||||||
fail(ENV, str(exc))
|
fail(ENV, str(exc))
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# Ключи секции `[docs]`. Секцию знает этот скрипт, а не общий читатель: ключ
|
||||||
|
# заводится вместе с проверкой, которая его читает.
|
||||||
|
DOCS_KEYS = ("migrations",)
|
||||||
|
|
||||||
|
|
||||||
def docs_cfg(cfg: dict) -> dict:
|
def docs_cfg(cfg: dict) -> dict:
|
||||||
@@ -307,15 +317,15 @@ def check_version(root: Path, cfg: dict, rep: Report) -> None:
|
|||||||
if got is None:
|
if got is None:
|
||||||
rep.error(f"в {CONFIG} нет ключа version — версия раскладки не объявлена")
|
rep.error(f"в {CONFIG} нет ключа version — версия раскладки не объявлена")
|
||||||
return
|
return
|
||||||
if got < CANON_VERSION:
|
if got < LAYOUT_VERSION:
|
||||||
rep.error(
|
rep.error(
|
||||||
f"проект приведён к раскладке версии {got}, текущая — {CANON_VERSION}: "
|
f"проект приведён к раскладке версии {got}, текущая — {LAYOUT_VERSION}:"
|
||||||
f"нужен canon upgrade"
|
f" нужно повышение (скилл av-dev:doc-canon, операция upgrade)"
|
||||||
)
|
)
|
||||||
elif got > CANON_VERSION:
|
elif got > LAYOUT_VERSION:
|
||||||
rep.error(
|
rep.error(
|
||||||
f"проект приведён к раскладке версии {got}, а скрипт знает"
|
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
|
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:
|
def check_required(root: Path, cfg: dict, rep: Report) -> None:
|
||||||
for rel, what in REQUIRED.items():
|
for rel, what in REQUIRED.items():
|
||||||
if (root / rel).exists():
|
if (root / rel).exists():
|
||||||
continue
|
continue
|
||||||
# Настройки под прежними именами — это не «нет файла», а незаконченный
|
if rel == CONFIG and conf.legacy_files(root):
|
||||||
# переезд. Без этой ветки проект слышал бы «нет версии» и шёл заводить
|
continue # об этом уже сказала check_legacy, и подробнее
|
||||||
# второй файл рядом с первым, а старые остались бы вторым домом.
|
|
||||||
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
|
|
||||||
rep.error(f"нет {rel} — {what}")
|
rep.error(f"нет {rel} — {what}")
|
||||||
|
|
||||||
for name, (kind, what) in DOCS.items():
|
for name, (kind, what) in DOCS.items():
|
||||||
@@ -640,6 +666,7 @@ def cmd_check(args: argparse.Namespace) -> int:
|
|||||||
rep = Report()
|
rep = Report()
|
||||||
cfg = read_config(root, rep)
|
cfg = read_config(root, rep)
|
||||||
check_version(root, cfg, rep)
|
check_version(root, cfg, rep)
|
||||||
|
check_legacy(root, rep)
|
||||||
check_required(root, cfg, rep)
|
check_required(root, cfg, rep)
|
||||||
check_stray(root, rep)
|
check_stray(root, rep)
|
||||||
check_slugs(root, rep)
|
check_slugs(root, rep)
|
||||||
@@ -652,10 +679,36 @@ def cmd_check(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
def cmd_version(args: argparse.Namespace) -> int:
|
def cmd_version(args: argparse.Namespace) -> int:
|
||||||
root = Path(args.dir).resolve()
|
root = Path(args.dir).resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
fail(ENV, f"нет каталога {root}")
|
||||||
cfg = read_config(root, Report())
|
cfg = read_config(root, Report())
|
||||||
got = conf.version(cfg)
|
got = conf.version(cfg)
|
||||||
print(f"раскладка скрипта: {CANON_VERSION}")
|
print(f"версия раскладки, скрипт: {LAYOUT_VERSION}")
|
||||||
print(f"раскладка проекта: {got if got is not None else 'не объявлена'}")
|
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
|
return OK
|
||||||
|
|
||||||
|
|
||||||
@@ -671,10 +724,14 @@ def main() -> int:
|
|||||||
p_check.add_argument("--base", default=None, help="база диффа для сверки миграций")
|
p_check.add_argument("--base", default=None, help="база диффа для сверки миграций")
|
||||||
p_check.set_defaults(func=cmd_check)
|
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.add_argument("--dir", default=".", help="корень проекта")
|
||||||
p_ver.set_defaults(func=cmd_version)
|
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()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|||||||
@@ -118,8 +118,11 @@ def _load_shared() -> ModuleType:
|
|||||||
дерева плагина в текущем каталоге нет.
|
дерева плагина в текущем каталоге нет.
|
||||||
"""
|
"""
|
||||||
path = Path(__file__).resolve().parents[3] / "shared" / "config.py"
|
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)
|
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} — общий читатель настроек;"
|
print(f"ОТКАЗ: не читается {path} — общий читатель настроек;"
|
||||||
f" переустанови плагин av-dev", file=sys.stderr)
|
f" переустанови плагин av-dev", file=sys.stderr)
|
||||||
sys.exit(3)
|
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
|
VERSION_KEY = conf.VERSION_KEY
|
||||||
|
|
||||||
EXIT_OK = 0
|
EXIT_OK = 0
|
||||||
@@ -517,6 +520,13 @@ def _validate_config(data: dict, path: Path) -> dict:
|
|||||||
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
|
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
|
||||||
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
|
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
|
||||||
raise Env(f"{path}: ключ «{key}» = «{value}» — только имя внутри каталога задач")
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -563,29 +573,37 @@ def version_problems(lay: Layout) -> list[str]:
|
|||||||
Заводит число `init`, двигает — операция `upgrade` скилла.
|
Заводит число `init`, двигает — операция `upgrade` скилла.
|
||||||
"""
|
"""
|
||||||
path = lay.project / CONFIG_NAME
|
path = lay.project / CONFIG_NAME
|
||||||
if not path.is_file():
|
|
||||||
legacy = conf.legacy_files(lay.project, lay.root)
|
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():
|
||||||
if legacy:
|
if legacy:
|
||||||
return [f"нет {path}, а прежняя раскладка на месте"
|
return [f"нет {path}, а прежняя раскладка на месте"
|
||||||
f" ({', '.join(legacy)}): перенеси настройки и удали старые"
|
f" ({', '.join(legacy)}): перенеси настройки и удали старые"
|
||||||
f" файлы операцией upgrade скилла av-dev:doc-canon"]
|
f" файлы операцией upgrade скилла av-dev:doc-canon"]
|
||||||
return [f"нет {path} — версия раскладки не объявлена."
|
return [f"нет {path} — версия раскладки не объявлена."
|
||||||
f" Заведи файл с «{VERSION_KEY} = {FORMAT_VERSION}» (журнал"
|
f" Заведи файл с «{VERSION_KEY} = {LAYOUT_VERSION}» (журнал"
|
||||||
f" версий — references/changelog.md скилла av-dev:doc-canon)"]
|
f" версий — references/changelog.md скилла av-dev:doc-canon)"]
|
||||||
# Что число целое, уже проверил общий читатель — иначе сюда не дошли бы
|
# Что число целое, уже проверил общий читатель — иначе сюда не дошли бы
|
||||||
# вовсе (код 3). Здесь `isinstance` значит ровно «ключ есть».
|
# вовсе (код 3). Здесь `isinstance` значит ровно «ключ есть».
|
||||||
got = conf.version(lay.full)
|
got = conf.version(lay.full)
|
||||||
if got is None:
|
if got is None:
|
||||||
return [f"{path}: нет ключа «{VERSION_KEY}» — версия раскладки не"
|
out.append(f"{path}: нет ключа «{VERSION_KEY}» — версия раскладки не"
|
||||||
f" объявлена, текущая {FORMAT_VERSION}"]
|
f" объявлена, текущая {LAYOUT_VERSION}")
|
||||||
if got < FORMAT_VERSION:
|
elif got < LAYOUT_VERSION:
|
||||||
return [f"проект приведён к раскладке версии {got}, текущая —"
|
out.append(f"проект приведён к раскладке версии {got}, текущая —"
|
||||||
f" {FORMAT_VERSION}: нужно повышение по журналу"
|
f" {LAYOUT_VERSION}: нужно повышение по журналу"
|
||||||
f" (скилл av-dev:doc-canon, операция upgrade)"]
|
f" (скилл av-dev:doc-canon, операция upgrade)")
|
||||||
if got > FORMAT_VERSION:
|
elif got > LAYOUT_VERSION:
|
||||||
return [f"проект приведён к раскладке версии {got}, а скрипт знает"
|
out.append(f"проект приведён к раскладке версии {got}, а скрипт знает"
|
||||||
f" {FORMAT_VERSION}: устарел плагин, обнови маркетплейс"]
|
f" {LAYOUT_VERSION}: устарел плагин, обнови маркетплейс")
|
||||||
return []
|
return out
|
||||||
|
|
||||||
|
|
||||||
def looks_like_tasks(p: Path, names: dict | None = None) -> bool:
|
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}")
|
f" новый проект — tasks.py init --dir {explicit}")
|
||||||
return Layout(root, names, project or root.resolve(), full)
|
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:
|
if project:
|
||||||
candidate = project / names.get(DIR_KEY, DEFAULT_DIR)
|
candidate = project / DEFAULT_DIR
|
||||||
if looks_like_tasks(candidate, names):
|
if looks_like_tasks(candidate, names):
|
||||||
return Layout(relative_if_inside(candidate, here), names, project, full)
|
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],
|
def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
|
||||||
cfg: dict) -> dict[Path, str]:
|
cfg: dict) -> dict[Path, str]:
|
||||||
out: dict[Path, str] = {}
|
out: dict[Path, str] = {}
|
||||||
# Служебный файл заводится всегда, даже когда все имена умолчательные: в нём
|
# Служебный файл здесь не заводится: его пишет `write_config` по живому
|
||||||
# живёт версия раскладки, а версия — не настройка, от которой можно
|
# файлу — версию двигает построчно, ключи дописывает, чужого не затирает.
|
||||||
# отказаться. Файл уже есть (проект под каноном, заводят только задачи) —
|
# Планом это сделать нельзя, потому что план перезаписывает целиком, а
|
||||||
# он не перезаписывается: комментарии в нём принадлежат человеку. Тогда
|
# перезапись стёрла бы комментарии — то, ради чего взят TOML.
|
||||||
# недостающие ключи секции дописываются построчно, и делает это `cmd_init`
|
|
||||||
# после записи файлов, потому что правка идёт по живому файлу, а не планом.
|
|
||||||
if not (lay.project / CONFIG_NAME).is_file():
|
|
||||||
out[lay.project / CONFIG_NAME] = conf.skeleton(FORMAT_VERSION, tasks=cfg)
|
|
||||||
out[lay.index("backlog")] = (
|
out[lay.index("backlog")] = (
|
||||||
"# Беклог\n\n"
|
"# Беклог\n\n"
|
||||||
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
||||||
@@ -2664,6 +2690,49 @@ def uniq_sections(raw: str) -> list[str]:
|
|||||||
return out
|
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:
|
def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||||
if not dir_within_cwd(root):
|
if not dir_within_cwd(root):
|
||||||
raise Usage(f"--dir вне рабочего каталога: {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():
|
for path, text in init_files(lay, sections, roadmap_sections, cfg).items():
|
||||||
plan.file(path, text)
|
plan.file(path, text)
|
||||||
plan.commit()
|
plan.commit()
|
||||||
added = conf.merge_section(project, "tasks", cfg) if cfg else []
|
said = write_config(project, cfg)
|
||||||
print(f"каталог задач заведён: {root}")
|
print(f"каталог задач заведён: {root}")
|
||||||
print(f" секции беклога: {', '.join(sections)};"
|
print(f" секции беклога: {', '.join(sections)};"
|
||||||
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
|
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
|
||||||
if added:
|
for line in said:
|
||||||
print(f" дописано в [tasks] {project / CONFIG_NAME}: {', '.join(added)}")
|
print(f" {line}")
|
||||||
print(f" версия раскладки в {project / CONFIG_NAME}: {FORMAT_VERSION}")
|
|
||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
@@ -3143,6 +3211,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
|||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
lay.items.mkdir(parents=True, exist_ok=True)
|
lay.items.mkdir(parents=True, exist_ok=True)
|
||||||
wr.commit()
|
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")]
|
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)
|
[tuple(pair) for pair in pl.get("path_map", [])], False)
|
||||||
|
|
||||||
print(f"каталог задач собран: {root}")
|
print(f"каталог задач собран: {root}")
|
||||||
|
for line in said:
|
||||||
|
print(f" {line}")
|
||||||
print(f" целей {len(pl.get('goals', []))}, задач {len(pl.get('items', []))},"
|
print(f" целей {len(pl.get('goals', []))}, задач {len(pl.get('items', []))},"
|
||||||
f" строк кладбища {len(pl.get('rejected', []))}")
|
f" строк кладбища {len(pl.get('rejected', []))}")
|
||||||
print(f" переименовано слагов: {len(renames)};"
|
print(f" переименовано слагов: {len(renames)};"
|
||||||
|
|||||||
Reference in New Issue
Block a user