Третий заход по находкам ревью — то, что старше темы 78 и тянулось с тем 74–77. Оснований у развилки три во всех местах: конвейер называл два, а устав триажа, контракт находок, сценарий решения и журнал — три. Там же сказано, чем третье отличается: по первым двум оркестратор урезает изменение до остатка, третье отменяет одобрение и возвращает на чекпоинт. Вопросы проекта по темам достались проходам, которые эти темы закрывают: review-code, review-specs и review-autotests получили обязанность отвечать дословно и строку в блоке покрытия. Прежде конвейер обещал их каждому проходу, а знал о них только приёмник тем. Глубокое ревью приведено к уставам, которые зовёт: глубина у проходов разная — доказательство у тех двоих, что держат машину, разбор у architecture и code; у триажа три вызывающих, а не два режима, и потолка в 7 пунктов там нет. Версия раскладки поднята до 5 с записью журнала: скелет docs/review.md потерял подраздел «Триггеры метки» ещё темой 77, а миграции проектам никто не дал. Сняты остатки меток в task-track и в config-skeleton, уезжающем в чужой проект. Перечень осей досчитал три оси: глубина темы, разметка действия, род правки. Журнал — тема 81.
356 lines
20 KiB
Python
356 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""Служебный файл проекта `.av-dev.toml`: чтение, запись, узнавание прежних.
|
||
|
||
**Это дом.** Файл один на весь плагин, поэтому и разбор у него один: `docs.py`
|
||
и `tasks.py` берут настройки отсюда, а не каждый своим кодом. Два разбора одного
|
||
формата — это два дома для одной схемы, и расходятся они молча: первым
|
||
разъезжается не значение ключа, а то, что скрипт делает, ключа не увидев.
|
||
|
||
Формат TOML выбран ради **комментариев**: файл лежит в чужом репозитории, и
|
||
человек, открывший его через полгода, обязан прочитать в нём, что означает
|
||
число. JSON комментариев не знает, и объяснение приходилось держать в
|
||
документации, то есть в другом файле.
|
||
|
||
Читается `tomllib` из стандартной библиотеки (python 3.11+), пишется руками:
|
||
писателя TOML в стандартной библиотеке нет, а комментарии переживают только
|
||
построчную правку. Поэтому версия двигается заменой одной строки, а не
|
||
перезаписью файла — иначе повышение канона стирало бы то, ради чего формат и
|
||
взят.
|
||
|
||
Схема:
|
||
|
||
version = 1 # версия раскладки av-dev, целое число
|
||
|
||
[docs]
|
||
migrations = "путь/к/миграциям" # необязателен: есть БД — есть ключ
|
||
|
||
[tasks]
|
||
dir = "tasks" # каталог задач от корня репозитория
|
||
stage = "build" # стадия проекта: build | support
|
||
items = "items" # имена частей каталога — необязательны
|
||
backlog = "BACKLOG.md"
|
||
|
||
Коды выхода зовущих скриптов — общий словарь av-dev; отсюда возвращается
|
||
исключение `ConfigError`, а решает по нему вызывающий.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import tomllib
|
||
from pathlib import Path
|
||
|
||
# Имя файла называет владельца: раскладку ведёт плагин `av-dev`. До слияния
|
||
# плагинов файлов было два — `docs/.docs.json` (версия канона) и
|
||
# `<каталог задач>/.tasks.json` (версия формата задач), и версии двигались
|
||
# порознь, потому что плагины ставились порознь. Плагин теперь один, версия
|
||
# одна, и дом у неё в корне репозитория: настройки нужны и проекту без `docs/`,
|
||
# и проекту без каталога задач, а корень есть у обоих.
|
||
CONFIG_NAME = ".av-dev.toml"
|
||
|
||
# Прежние дома. Читаются не для работы, а для узнавания: увидели — говорим
|
||
# «старая раскладка, нужен upgrade», и это одна строка вместо отказа, за которым
|
||
# человек идёт заводить второй файл рядом с первым.
|
||
LEGACY = ("docs/.docs.json", "docs/.pm.json")
|
||
LEGACY_TASKS = ".tasks.json"
|
||
|
||
# Версия раскладки — одна на плагин. Журнал версий — references/changelog.md
|
||
# скилла `canon`, повышает его операция `upgrade`.
|
||
VERSION = 5
|
||
|
||
VERSION_KEY = "version"
|
||
|
||
|
||
class ConfigError(Exception):
|
||
"""Файл есть, но прочитать его нельзя: битый TOML или не та схема."""
|
||
|
||
|
||
def find_root(start: Path | None = None) -> Path | None:
|
||
"""Корень проекта: где лежит `.av-dev.toml`, иначе где лежит `.git`.
|
||
|
||
Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже
|
||
можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам
|
||
решает, отказ это или неприменимость.
|
||
|
||
**Подъём останавливается на первом `.git`, и это не деталь.** Репозиторий
|
||
внутри репозитория — обычное дело, и без границы конфиг соседа выигрывал бы
|
||
у собственного: вложенный проект объявлялся бы здоровым по чужому файлу, а
|
||
запись настроек уходила бы в чужой репозиторий. Свой файл ищется **до**
|
||
границы включительно, чужой не ищется вовсе.
|
||
"""
|
||
here = (start or Path.cwd()).resolve()
|
||
for base in (here, *here.parents):
|
||
if (base / CONFIG_NAME).is_file():
|
||
return base
|
||
if (base / ".git").exists():
|
||
return base # корень репозитория есть, настроек в нём нет
|
||
return None
|
||
|
||
|
||
def read(root: Path) -> dict:
|
||
"""Настройки проекта. Файла нет — пустой словарь, это не ошибка."""
|
||
path = root / CONFIG_NAME
|
||
if not path.is_file():
|
||
return {}
|
||
try:
|
||
# Читаем байтами: `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, 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)):
|
||
raise ConfigError(
|
||
f"{CONFIG_NAME}: ключ «{VERSION_KEY}» — версия раскладки,"
|
||
f" ожидалось целое число, а не {got!r}"
|
||
)
|
||
for name in ("docs", "tasks"):
|
||
got_section = data.get(name)
|
||
if got_section is not None and not isinstance(got_section, dict):
|
||
raise ConfigError(
|
||
f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек,"
|
||
f" а не {got_section!r}"
|
||
)
|
||
check_keys(data, TOP_KEYS, "верхнего уровня")
|
||
|
||
|
||
def section(cfg: dict, name: str) -> dict:
|
||
got = cfg.get(name, {})
|
||
return got if isinstance(got, dict) else {}
|
||
|
||
|
||
def version(cfg: dict) -> int | None:
|
||
got = cfg.get(VERSION_KEY)
|
||
return got if isinstance(got, int) and not isinstance(got, bool) else None
|
||
|
||
|
||
def legacy_files(root: Path, tasks_dir: Path | None = None) -> list[str]:
|
||
"""Следы прежней раскладки — то, что говорит «проект жил до слияния».
|
||
|
||
Каталог задач передаётся отдельно: до чтения настроек его путь неизвестен, а
|
||
искать `.tasks.json` по всему дереву значит гадать.
|
||
"""
|
||
root = root.resolve()
|
||
found = [rel for rel in LEGACY if (root / rel).is_file()]
|
||
for base in filter(None, (tasks_dir, root / "tasks", root / "docs" / "tasks")):
|
||
# Каталог задач приходит и относительным — таким его печатают в
|
||
# сообщениях; для сравнения с корнем он обязан быть абсолютным.
|
||
path = (base if base.is_absolute() else Path.cwd() / base) / LEGACY_TASKS
|
||
if not path.is_file():
|
||
continue
|
||
path = path.resolve()
|
||
rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path)
|
||
if rel not in found:
|
||
found.append(rel)
|
||
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:
|
||
"""Двинуть версию, не тронув остального: правится одна строка.
|
||
|
||
Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего
|
||
формат и выбран.
|
||
|
||
**Ищется только ключ верхнего уровня** — то есть выше первого заголовка
|
||
секции. `version` внутри `[docs]` принадлежит проекту и значит что угодно
|
||
своё; двинув его, мы объявили бы приведённым не то, о чём речь, и оставили
|
||
бы настоящую версию неназванной. Ключа нет вовсе — строка встаёт первой, до
|
||
всякой секции, по той же причине.
|
||
"""
|
||
path = root / CONFIG_NAME
|
||
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:
|
||
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]:
|
||
"""Дописать ключи в секцию, не тронув остального. Возвращает дописанное.
|
||
|
||
Правка построчная по той же причине, что и у версии: перезапись файла
|
||
целиком стёрла бы комментарии. Ключ, который в секции уже есть, не трогается
|
||
вовсе — файл в чужом репозитории правит человек, и затирать его значение
|
||
своим умолчанием нельзя. **Что дописано, а что нет, решает зовущий:** список
|
||
возвращается, и молчать о неписаном ему нельзя.
|
||
"""
|
||
path = root / CONFIG_NAME
|
||
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
||
start = next((i for i, ln in enumerate(lines) if _is_header(ln, name)), None)
|
||
if start is None:
|
||
if not values:
|
||
return []
|
||
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 _is_header(lines[i])),
|
||
len(lines))
|
||
body = lines[start + 1:end]
|
||
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 []
|
||
# Пустые строки в хвосте секции — отбивка перед следующим заголовком.
|
||
# Дописываем до неё, а её возвращаем на место: иначе файл слипается.
|
||
trailing = 0
|
||
while body and not body[-1].strip():
|
||
body.pop()
|
||
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 set_section_key(root: Path, name: str, key: str, value: str) -> None:
|
||
"""Заменить значение ключа секции, не тронув остального.
|
||
|
||
Отличается от `merge_section` ровно тем, ради чего и заведена: та **не
|
||
трогает** ключ, который уже есть, потому что дописывает умолчания в чужой
|
||
файл. Здесь же значение меняет команда, которую позвал человек, и не
|
||
переписать его значило бы промолчать о выполненном действии. Ключа нет —
|
||
он дописывается, секции нет — заводится: и то и другое законное состояние
|
||
файла, который правят руками.
|
||
"""
|
||
path = root / CONFIG_NAME
|
||
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
||
start = next((i for i, ln in enumerate(lines) if _is_header(ln, name)), None)
|
||
if start is None:
|
||
merge_section(root, name, {key: value})
|
||
return
|
||
end = next((i for i in range(start + 1, len(lines)) if _is_header(lines[i])),
|
||
len(lines))
|
||
pattern = re.compile(rf"^(\s*{re.escape(key)}\s*=\s*)([^#]*?)(\s*(?:#.*)?)$")
|
||
for i in range(start + 1, end):
|
||
if (match := pattern.match(lines[i])):
|
||
lines[i] = f"{match.group(1)}{quote(value)}{match.group(3)}"
|
||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return
|
||
merge_section(root, name, {key: value})
|
||
|
||
|
||
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.
|
||
|
||
Пустая секция пишется всё равно: строка «ключа нет, потому что БД нет»
|
||
читается как решение, а её отсутствие — как недосмотр.
|
||
"""
|
||
docs, tasks = docs or {}, tasks or {}
|
||
out = [
|
||
"# Раскладка av-dev в этом проекте: версия и настройки проверок.",
|
||
"# Файл ведут скиллы плагина, править руками можно — комментарии свои.",
|
||
"",
|
||
f"{VERSION_KEY} = {number}"
|
||
" # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»",
|
||
"",
|
||
"[docs]",
|
||
]
|
||
if docs.get("migrations"):
|
||
out += [
|
||
"# каталог миграций: по нему docs.py сверяет схему с database.md",
|
||
f"migrations = {quote(docs['migrations'])}",
|
||
]
|
||
else:
|
||
out += ['# migrations = "путь/к/миграциям" — появится, когда появится БД']
|
||
out += ["", "[tasks]",
|
||
"# каталог задач от корня репозитория; имена частей — умолчания скрипта",
|
||
f"dir = {quote(tasks.get('dir', 'tasks'))}"]
|
||
if tasks.get("stage"):
|
||
out += ["# стадия проекта: build — беклог это план стройки, порядок строк"
|
||
" значит зависимость;",
|
||
"# support — беклог это очередь правок, порядок значит важность",
|
||
f"stage = {quote(tasks['stage'])}"]
|
||
for key in ("items", "backlog", "rejected"):
|
||
if tasks.get(key):
|
||
out.append(f"{key} = {quote(tasks[key])}")
|
||
return "\n".join(out) + "\n"
|