задачи: починена смена стадии, разобраны находки ревью плагина
Команда stage была дефектна по шести пунктам, и все шесть подтверждены прогоном: не звала raw_last (переход оставлял каталог красным), не переписывала шапку беклога (индекс продолжал объявлять прежнюю стадию), шла в обход write_config, молча пропускала файлы с непересобираемой метой, ломалась на беклоге без заголовков и схлопывала полки при первом объявлении стадии. Объявление и смена разведены: объявление беклога не трогает вовсе, смена трогает состав секций только по явному --sections, а слить полки скрипт не берётся ни в одном случае. Абзац шапки размечен парой «стадия», и расхождение с конфигом стало обычным дрейфом. Отказ по недостающей строке индекса запирал запись, пережившую упразднение роадмапа: edit, close и reopen теперь заводят или пропускают строку сами. Прочее: регистр stage нормализуется при чтении; --fix снимает мёртвые теги и у неразобранных записей; move отказывает переставлять сырьё; adopt держит место сырья; docs.py bump двигает одну запись журнала за раз; tasks.py получил перечень упразднённых адресов, и гейт наконец видит собственное упразднение ROADMAP.md. Запись «Версия 3» переписана по прогону на игрушечном проекте: прежний порядок шагов был неисполним. Закрыты дыры модели стадий (пересмотр плана стройки стал сценарием, приёмка отвязана от груминга, from-review, research и adopt получили развилку по стадии, перечень осей пересчитан) и находки, старшие этой сессии: review-triage получил режим без метки, три списка проектных копий сведены к дому с проверяемыми копиями, пять пересказов правил стали помеченными копиями или ссылками, language.md перестал объявлять юрисдикцию над чужим плагином.
This commit is contained in:
@@ -189,6 +189,16 @@ DEFAULTS = {
|
||||
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
||||
PATH_KEYS = ("items", "backlog", "rejected")
|
||||
|
||||
# Упразднённые части каталога — с адресом, куда уехало содержимое. Перечень
|
||||
# читает `scripts/addresses.py`: адрес, названный в чужой прозе, опровергается
|
||||
# перечнем владельца, а не памятью. Без этой константы упразднение, сделанное
|
||||
# здесь, не ловилось бы гейтом вовсе — то есть шаг гейта молчал бы ровно про то,
|
||||
# ради чего заведён.
|
||||
RETIRED = {
|
||||
"ROADMAP.md": "→ BACKLOG.md: роадмап упразднён вместе с типом goal",
|
||||
"SPRINT.md": "→ порядок строк беклога (спринты отменены)",
|
||||
}
|
||||
|
||||
# Умолчания секций по стадиям. На доработке это **полки домена**: смысла они не
|
||||
# несут, называет их проект. На стройке секция ровно одна — список от базы к
|
||||
# деталям, — и её имя тоже дело проекта: различать ей нечего, она одна.
|
||||
@@ -262,6 +272,10 @@ BULLET = re.compile(r"^[-*]\s+(.*)$")
|
||||
REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+")
|
||||
|
||||
RESEARCH = "research"
|
||||
# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; читается
|
||||
# только затем, чтобы `check` назвал его вслух, а `--fix` снял его следы.
|
||||
LEGACY_GOAL_TAG = "goal:"
|
||||
LEGACY_GOAL = "goal"
|
||||
# Тип — ось записи и **закрытый словарь**. Открытый разъедется на синонимах
|
||||
# (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет отвечать на
|
||||
# свой единственный вопрос. Ни один тип не подходит — это сигнал, что в записи
|
||||
@@ -272,17 +286,16 @@ TYPES = ("feature", "fix", "chore", RESEARCH)
|
||||
# дословно, — так тип виден там, где решают «брать или не брать», и инвариант
|
||||
# «заголовок в индексе дословно» остаётся нетронутым.
|
||||
TYPE_EMOJI = {"feature": "✨", "fix": "🐞", "chore": "🧹", RESEARCH: "🔬"}
|
||||
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()}
|
||||
# Эмодзи упразднённого типа читается по-прежнему — иначе она не **снимается**:
|
||||
# заголовок разбирается на «значок + текст», и незнакомый значок уезжает в текст,
|
||||
# а следующая правка типа ставит второй перед первым («✨ 🎯 …»).
|
||||
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} | {"🎯": LEGACY_GOAL}
|
||||
TAKEABLE = TYPES # берутся в работу все четыре: целей больше нет
|
||||
# Заголовок в форме действия требуется там, где исход работы — изменение
|
||||
# системы. У разведки он называет предмет: её исход знание, и заголовок-действие
|
||||
# обещал бы решённость, которой ещё нет.
|
||||
ACTION_TYPES = ("feature", "fix", "chore")
|
||||
QUESTION_TAG = "question"
|
||||
# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; тег
|
||||
# читается только затем, чтобы `check` назвал его вслух, а `--fix` снял.
|
||||
LEGACY_GOAL_TAG = "goal:"
|
||||
LEGACY_GOAL = "goal"
|
||||
|
||||
# Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения —
|
||||
# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает,
|
||||
@@ -458,7 +471,12 @@ class Layout:
|
||||
# Стадия — не имя части, поэтому и не в `cfg`. Пустая строка значит «не
|
||||
# объявлена», и это отдельное состояние: без неё непонятно, что значит
|
||||
# порядок строк, и `check` об этом говорит.
|
||||
self.stage = cfg.get(STAGE_KEY, "")
|
||||
#
|
||||
# Приводится к нижнему регистру ровно потому, что к нему же приводит
|
||||
# валидация: `stage = "Build"` проходил её и не совпадал ни с одним
|
||||
# значением здесь, так что каждое ветвление молча уходило в ветку
|
||||
# «стадии нет» — при зелёном конфиге и объявленной стадии.
|
||||
self.stage = str(cfg.get(STAGE_KEY, "")).strip().lower()
|
||||
|
||||
def index(self, kind: str) -> Path:
|
||||
return self.root / self.cfg[kind]
|
||||
@@ -1145,6 +1163,7 @@ def check(lay: Layout, fix: bool = False) -> int:
|
||||
raw_names = {n for n, t in tasks.items() if raw_research(lay, t)}
|
||||
|
||||
errors += sections_verdict(lay, sections, label)
|
||||
errors += stage_block_verdict(lay, lines, label)
|
||||
for s in sections:
|
||||
if s.lower() in BLOCKER_SECTIONS:
|
||||
notes.append(f"{label}: секции «{s}» быть не должно —"
|
||||
@@ -1457,6 +1476,12 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
|
||||
for t in rows:
|
||||
t["touched"] = dates.get(t["path"].name, "—")
|
||||
rows.sort(key=lambda t: (t["touched"] == "—", t["touched"]))
|
||||
# Отбор работает на любой стадии, но значит он разное, и молчать об этом
|
||||
# нельзя: на стройке шаг лежит долго законно — до него не дошла очередь,
|
||||
# и он стоит там, где стоит, по зависимости.
|
||||
if lay.stage == BUILD:
|
||||
print(" стройка: залежалость здесь не мера — шаг ждёт своей"
|
||||
" очереди по зависимости, а не потому, что его обходят\n")
|
||||
else:
|
||||
rows.sort(key=lambda t: (order.get(t["section"], 99), t["path"].name))
|
||||
|
||||
@@ -1700,8 +1725,8 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int:
|
||||
section = a.section or (section_headers(lines)[0][1] if section_headers(lines) else "")
|
||||
hi, section = find_section(lines, section)
|
||||
if hi is None:
|
||||
avail = ", ".join(n for _, n in section_headers(lines))
|
||||
raise Usage(f"нет секции «{a.section}» в {lay.name('backlog')} (есть: {avail})")
|
||||
avail = ", ".join(n for _, n in section_headers(lines)) or "ни одной"
|
||||
raise Usage(f"нет секции «{section}» в {lay.name('backlog')} (есть: {avail})")
|
||||
|
||||
tags = split_tags(a.tag)
|
||||
if QUESTION_TAG in tags:
|
||||
@@ -1766,9 +1791,6 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
if not path.exists():
|
||||
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
|
||||
place = locate(lay, a.slug)
|
||||
if place is None:
|
||||
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
|
||||
|
||||
task = parse_task(path)
|
||||
if a.title is not None and not a.title.strip():
|
||||
raise Usage("пустой заголовок")
|
||||
@@ -1802,6 +1824,21 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
# правкой. Сверка принадлежности всё равно идёт по нижнему регистру.
|
||||
section = task["section_raw"]
|
||||
|
||||
# Строки индекса может не быть, и это не всегда поломка: запись, пережившая
|
||||
# упразднение своего индекса, лежит файлом без строки, а `check --fix`
|
||||
# восстановить её не может — секция в мете указывает на исчезнувшую полку.
|
||||
# Отказ здесь запирал бы такую запись навсегда: строку не восстановить, пока
|
||||
# не сменишь тип, и тип не сменить, пока нет строки. Заводим строку сами, в
|
||||
# первую секцию, и говорим об этом.
|
||||
lost_section = ""
|
||||
if place is None:
|
||||
heads = section_headers(read_lines(lay.index("backlog")))
|
||||
if not heads:
|
||||
raise Usage(f"строки для {a.slug} нет, и в {lay.name('backlog')} нет"
|
||||
f" ни одной секции — заводить её некуда")
|
||||
if find_section(read_lines(lay.index("backlog")), section)[0] is None:
|
||||
lost_section, section = section or "—", heads[0][1]
|
||||
|
||||
# Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего
|
||||
# на поле, он выведен из прежнего дома, и без него пересборка меты потеряла
|
||||
# бы его вовсе.
|
||||
@@ -1823,8 +1860,12 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
if rtype == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower()):
|
||||
raw_now.add(f"{a.slug}.md")
|
||||
|
||||
lines, ei = place
|
||||
lines[ei] = entry_line(lay, h1, a.slug, why)
|
||||
if place is None:
|
||||
lines = read_lines(lay.index("backlog"))
|
||||
insert_entry(lines, section, entry_line(lay, h1, a.slug, why))
|
||||
else:
|
||||
lines, ei = place
|
||||
lines[ei] = entry_line(lay, h1, a.slug, why)
|
||||
|
||||
plan = Plan()
|
||||
plan.file(path, new_text)
|
||||
@@ -1835,6 +1876,11 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
("теги", a.add_tag or a.rm_tag))
|
||||
if v is not None]
|
||||
print(f"{a.slug}: обновлено ({', '.join(changed)})")
|
||||
if place is None:
|
||||
print(f" строки в {lay.name('backlog')} не было — заведена в секции"
|
||||
f" «{section}», в конец: позицию назначает человек"
|
||||
+ (f" (секции «{lost_section}» из меты в индексе нет)"
|
||||
if lost_section else ""))
|
||||
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]:
|
||||
print(f" вопрос открыт — в работу задача не берётся, пока он не разобран"
|
||||
f" (`tasks.py ready {a.slug}` это и скажет)")
|
||||
@@ -1844,11 +1890,16 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
|
||||
def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
|
||||
"""Перестановка строки: внутри своей секции или в другую.
|
||||
|
||||
**Что значит перестановка, говорит стадия, а не эта команда**: на стройке она
|
||||
называет зависимость («этот шаг делается после того»), на доработке —
|
||||
приоритет («это берут раньше»). Отсюда и `--reason`: причина у двух движений
|
||||
разная, и через месяц её не восстановить.
|
||||
|
||||
`--section` необязателен, и это не удобство. Перестановка внутри секции —
|
||||
самая частая операция груминга (`move --after` и есть расстановка
|
||||
приоритета), а требовать в ней повторить текущую секцию значит приглашать
|
||||
указать не ту: перенос в чужую секцию выглядел бы ровно так же. Без
|
||||
`--section` секция берётся из индекса — та, в которой строка уже лежит.
|
||||
самая частая операция и там и там, а требовать в ней повторить текущую секцию
|
||||
значит приглашать указать не ту: перенос в чужую секцию выглядел бы ровно так
|
||||
же. Без `--section` секция берётся из индекса — та, в которой строка уже
|
||||
лежит.
|
||||
"""
|
||||
for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None):
|
||||
if err:
|
||||
@@ -1873,6 +1924,15 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
|
||||
avail = ", ".join(n for _, n in section_headers(lines))
|
||||
raise Usage(f"нет секции «{a.section}» в {label} (есть: {avail})")
|
||||
task = parse_task(path)
|
||||
# Место сырья производно от типа, а не назначается: назначить его — значит
|
||||
# получить дрейф, который следующий же `check --fix` отменит, стерев решение
|
||||
# человека. Поэтому отказ, и с названным выходом: сырьё перестаёт быть
|
||||
# сырьём, как только у него появляется «Вопрос».
|
||||
if (a.after or a.first) and raw_research(lay, task):
|
||||
raise Usage(f"{a.slug} — сырьё (`{RESEARCH}` без раздела"
|
||||
f" «{lay.cfg['question_heading']}»), и место у него не"
|
||||
f" назначается: конец секции, потому что его не берут."
|
||||
f" Допиши «{lay.cfg['question_heading']}» — и переставляй")
|
||||
new_text = meta_updated(path, section=section, reason=a.reason,
|
||||
rtype=task["type"] or None)
|
||||
if new_text is None:
|
||||
@@ -1883,9 +1943,12 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
|
||||
insert_entry(lines, section, entry, a.after, a.first)
|
||||
except KeyError as e:
|
||||
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e
|
||||
if not (a.after or a.first):
|
||||
lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items()
|
||||
if raw_research(lay, t)})
|
||||
# Сырьё сносится в конец **всегда**, в том числе после `--after`/`--first`:
|
||||
# переставленная строка от этого не двигается (она не сырьё — отказ выше),
|
||||
# а вот сырьё, оказавшееся выше неё, встаёт на своё место сразу, а не до
|
||||
# ближайшего `check --fix`.
|
||||
lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items()
|
||||
if raw_research(lay, t)})
|
||||
|
||||
plan = Plan()
|
||||
plan.file(path, new_text)
|
||||
@@ -1905,9 +1968,10 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
|
||||
path = lay.items / f"{a.slug}.md"
|
||||
if not path.exists():
|
||||
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
|
||||
# Строки может не быть, и отказ здесь запирал бы запись навсегда: чтобы
|
||||
# строку восстановить, надо её куда-то класть, а класть незачем — запись
|
||||
# закрывают. Так закрывается и то, что пережило упразднение своего индекса.
|
||||
place = locate(lay, a.slug)
|
||||
if place is None:
|
||||
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
|
||||
task = parse_task(path)
|
||||
|
||||
plan = Plan()
|
||||
@@ -1922,13 +1986,16 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
|
||||
if not prev.endswith("\n"):
|
||||
prev += "\n"
|
||||
plan.file(rej, prev + bullet + "\n")
|
||||
lines, ei = place
|
||||
lines.pop(ei)
|
||||
plan.index(lay, "backlog", lines)
|
||||
if place is not None:
|
||||
lines, ei = place
|
||||
lines.pop(ei)
|
||||
plan.index(lay, "backlog", lines)
|
||||
plan.delete(path)
|
||||
plan.commit()
|
||||
|
||||
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
|
||||
if place is None:
|
||||
print(f" строки в {lay.name('backlog')} не было — удалён только файл")
|
||||
if not a.reason:
|
||||
print(" дорога назад: файл восстанавливается из git —"
|
||||
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
|
||||
@@ -1987,19 +2054,32 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
|
||||
else:
|
||||
text = upd
|
||||
tmp = parse_task_text(text, path)
|
||||
plan = Plan()
|
||||
plan.file(path, text)
|
||||
|
||||
# Написание — из меты как есть: имя секции уедет в доклад, а сверка
|
||||
# принадлежности всё равно идёт по нижнему регистру (`find_section`).
|
||||
section = tmp["section_raw"] or tmp["section"]
|
||||
lines = read_lines(lay.index("backlog"))
|
||||
heads = section_headers(lines)
|
||||
lost_section = ""
|
||||
if find_section(lines, section)[0] is None:
|
||||
# Секции могло не стать законно: смена стадии переразмечает беклог, и
|
||||
# задача, закрытая до перехода, ссылается на исчезнувшую полку. Отказ
|
||||
# здесь означал бы, что закрытое до перехода не возвращается никогда, —
|
||||
# а `reopen` заведён ровно на случай, когда приёмка не сошлась. Кладём в
|
||||
# первую секцию, правим мету и говорим об этом вслух.
|
||||
if not heads:
|
||||
raise Usage(f"в {lay.name('backlog')} нет ни одной секции —"
|
||||
f" возвращать некуда")
|
||||
lost_section, section = section, heads[0][1]
|
||||
rebuilt = meta_rebuilt(text.splitlines(), section=section)
|
||||
if rebuilt is not None:
|
||||
text = "\n".join(rebuilt) + "\n"
|
||||
tmp = parse_task_text(text, path)
|
||||
|
||||
plan = Plan()
|
||||
plan.file(path, text)
|
||||
if find_entry_index(lines, a.slug) is None:
|
||||
hi, sec = find_section(lines, section)
|
||||
if hi is None:
|
||||
avail = ", ".join(n for _, n in section_headers(lines))
|
||||
raise Usage(f"секции «{section}» нет в {lay.name('backlog')} (есть: {avail})")
|
||||
insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"]))
|
||||
insert_entry(lines, section, entry_line(lay, title, a.slug, tmp["why"]))
|
||||
# Место сырья производно от типа, и `reopen` обязан его соблюсти сразу:
|
||||
# вернуть разведку без «Вопроса» просто в конец секции — значит
|
||||
# поставить её после сырья, лежавшего там раньше, и получить ошибку
|
||||
@@ -2028,8 +2108,11 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
|
||||
# а его назначает человек. Молча вернуть задачу наверх очереди значило бы
|
||||
# принять за него решение, которого он не принимал.
|
||||
print(f"{a.slug}: возвращён в {lay.name('backlog')} из истории git"
|
||||
f" (секция «{section}», в конец: позиция это приоритет, и её"
|
||||
f" назначает человек)")
|
||||
f" (секция «{section}», в конец: позицию назначает человек)")
|
||||
if lost_section:
|
||||
print(f" секции «{lost_section}» в беклоге больше нет — положен в"
|
||||
f" «{section}», «{PLACE_KEY}» в файле поправлена. Так бывает после"
|
||||
f" смены стадии: состав секций там переразмечается")
|
||||
for line in removed:
|
||||
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
|
||||
print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее"
|
||||
@@ -2181,6 +2264,37 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
# принимают решение.
|
||||
for name, task in tasks.items():
|
||||
rtype = task["type"]
|
||||
# Мёртвые теги и прежнее имя поля места снимаются у **любой** записи,
|
||||
# включая ту, чей тип машина исправить не берётся. Тег `goal:` мёртв
|
||||
# независимо от того, что за запись его несёт, а «Секция» — прежнее имя
|
||||
# того же самого поля. Стой эта правка после разбора типа, перевод
|
||||
# проекта оставлял бы их ровно в файлах целей — то есть в тех, которые
|
||||
# человек как раз и разбирает руками, и разбирал бы он их с мусором.
|
||||
want_key = PLACE_KEY.lower()
|
||||
tags = [t for t in task["tags"]
|
||||
if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG))
|
||||
and t != LEGACY_DECOMPOSED_TAG]
|
||||
renamed = bool(task["place_key"]) and task["place_key"] != want_key
|
||||
retyped = bool(rtype) and rtype in TYPES and task["meta_type"] != rtype
|
||||
if tags != task["tags"] or renamed or retyped:
|
||||
if not stage(task, rtype=rtype if retyped else None,
|
||||
tags=tags if tags != task["tags"] else None):
|
||||
ambiguous.append(f"{name}: чинить мету некуда — в файле нет"
|
||||
f" мета-блока")
|
||||
else:
|
||||
what = []
|
||||
if retyped:
|
||||
what.append(f"тип «{rtype}» в поле **Тип:**")
|
||||
if task["legacy_kind"]:
|
||||
what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}")
|
||||
if task["legacy_goal"]:
|
||||
what.append(f"снят тег {task['legacy_goal']}")
|
||||
if LEGACY_DECOMPOSED_TAG in task["tags"]:
|
||||
what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}")
|
||||
if renamed:
|
||||
what.append(f"поле места → «{PLACE_KEY}»")
|
||||
fixed.append(f"{name}: " + ", ".join(what))
|
||||
|
||||
if not rtype:
|
||||
ambiguous.append(f"{name}: тип не выводится — нет ни поля **Тип:**, ни"
|
||||
f" тега {LEGACY_KIND_TAG}<род>, ни префикса заголовка."
|
||||
@@ -2197,29 +2311,6 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
f" ({', '.join(TYPES)}) — чем он заменяется,"
|
||||
f" решает человек")
|
||||
continue
|
||||
want_key = PLACE_KEY.lower()
|
||||
# Прежние теги снимаются здесь же: у обоих не осталось того, на что они
|
||||
# указывали, — целей нет, а разбирать нечего.
|
||||
tags = [t for t in task["tags"]
|
||||
if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG))
|
||||
and t != LEGACY_DECOMPOSED_TAG]
|
||||
renamed = bool(task["place_key"]) and task["place_key"] != want_key
|
||||
if task["meta_type"] != rtype or tags != task["tags"] or renamed:
|
||||
if not stage(task, rtype=rtype,
|
||||
tags=tags if tags != task["tags"] else None):
|
||||
ambiguous.append(f"{name}: тип «{rtype}» переносить некуда —"
|
||||
f" в файле нет мета-блока")
|
||||
else:
|
||||
what = [f"тип «{rtype}» в поле **Тип:**"]
|
||||
if task["legacy_kind"]:
|
||||
what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}")
|
||||
if task["legacy_goal"]:
|
||||
what.append(f"снят тег {task['legacy_goal']}")
|
||||
if LEGACY_DECOMPOSED_TAG in task["tags"]:
|
||||
what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}")
|
||||
if renamed:
|
||||
what.append(f"поле места → «{PLACE_KEY}»")
|
||||
fixed.append(f"{name}: " + ", ".join(what))
|
||||
want_h1 = h1_of(rtype, task["bare"])
|
||||
if task["title"] and task["title"] != want_h1:
|
||||
src = staged_lines(task)
|
||||
@@ -2287,11 +2378,14 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
insert_entry(idx[kind], section, entry_line(lay, task["title"], name[:-3],
|
||||
task["why"]))
|
||||
# Восстановленная строка встаёт в конец секции, и это надо сказать:
|
||||
# позиция в беклоге — приоритет, а его назначает человек. Молчаливое
|
||||
# восстановление выдало бы машинную позицию за его решение.
|
||||
# позицию назначает человек, а машинная выдала бы себя за его
|
||||
# решение. Чем именно она была бы — зависимостью или приоритетом, —
|
||||
# решает стадия, и назвать её тут дешевле, чем заставлять вспоминать.
|
||||
means = ("зависимость" if lay.stage == BUILD else
|
||||
"приоритет" if lay.stage == SUPPORT else "порядок работ")
|
||||
fixed.append(f"{lay.name(kind)}: восстановлена строка {name}"
|
||||
" — в конце секции, позицию назначь сам:"
|
||||
" порядок строк это приоритет"
|
||||
f" — в конце секции, позицию назначь сам:"
|
||||
f" порядок строк это {means}"
|
||||
+ ("" if task["why"] else " (в файле нет «зачем» — допиши)"))
|
||||
dirty.add(kind)
|
||||
continue
|
||||
@@ -2311,6 +2405,13 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
# имеет; тем более он не сливает их на стройке — в каком порядке пойдут
|
||||
# строки слитых полок, знает только человек.
|
||||
for kind, lines in idx.items():
|
||||
if kind == "backlog" and lay.stage in STAGES:
|
||||
span = stage_block_span(lines)
|
||||
if span is not None and lines[span[0]:span[1]] != stage_block(lay.stage):
|
||||
lines[span[0]:span[1]] = stage_block(lay.stage)
|
||||
fixed.append(f"{lay.name(kind)}: шапка переписана под стадию"
|
||||
f" «{STAGE_RU[lay.stage]}»")
|
||||
dirty.add(kind)
|
||||
raw = {n for n, t in tasks.items() if raw_research(lay, t)}
|
||||
if (moved := raw_last(lines, raw)) != lines:
|
||||
lines[:] = moved
|
||||
@@ -2360,9 +2461,50 @@ BACKLOG_ORDER = {
|
||||
"одной, по мере появления; пустой беклог — нормальное состояние.",
|
||||
}
|
||||
|
||||
# Абзац шапки, объявляющий стадию, размечен парой комментариев — и это не
|
||||
# украшение. Стадия решает, что значит порядок строк, а читают об этом **здесь**:
|
||||
# индекс открывают вместо документации. Без разметки `stage` не знал бы, что
|
||||
# переписывать, и абзац продолжал бы называть прежнюю стадию — молча и навсегда.
|
||||
# С разметкой расхождение шапки с конфигом становится обычным дрейфом: `check`
|
||||
# его называет, `check --fix` правит.
|
||||
STAGE_OPEN = "<!-- стадия -->"
|
||||
STAGE_CLOSE = "<!-- /стадия -->"
|
||||
|
||||
def init_files(lay: Layout, sections: list[str], stage_name: str,
|
||||
cfg: dict) -> dict[Path, str]:
|
||||
|
||||
def stage_block(stage_name: str) -> list[str]:
|
||||
return [STAGE_OPEN,
|
||||
f"Стадия проекта — **{STAGE_RU[stage_name]}**"
|
||||
f' (`[tasks] {STAGE_KEY} = "{stage_name}"`).',
|
||||
BACKLOG_ORDER[stage_name],
|
||||
STAGE_CLOSE]
|
||||
|
||||
|
||||
def stage_block_span(lines: list[str]) -> tuple[int, int] | None:
|
||||
"""Границы размеченного абзаца — `[начало, конец)`. None, если разметки нет."""
|
||||
try:
|
||||
start = next(i for i, ln in enumerate(lines) if ln.strip() == STAGE_OPEN)
|
||||
end = next(i for i in range(start + 1, len(lines))
|
||||
if lines[i].strip() == STAGE_CLOSE)
|
||||
except StopIteration:
|
||||
return None
|
||||
return start, end + 1
|
||||
|
||||
|
||||
def stage_block_verdict(lay: Layout, lines: list[str], label: str) -> list[str]:
|
||||
"""Шапка беклога против конфига. Разметки нет — молчим: индекс мог быть
|
||||
заведён до её появления, и требовать её от чужого файла не за что."""
|
||||
span = stage_block_span(lines)
|
||||
if span is None or lay.stage not in STAGES:
|
||||
return []
|
||||
want = "\n".join(stage_block(lay.stage))
|
||||
if "\n".join(lines[span[0]:span[1]]).strip() == want.strip():
|
||||
return []
|
||||
return [f"{label}: шапка объявляет не ту стадию, что конфиг"
|
||||
f" ({STAGE_RU[lay.stage]}) — а читают о смысле порядка строк"
|
||||
f" именно её; перепишет `check --fix`"]
|
||||
|
||||
|
||||
def init_files(lay: Layout, sections: list[str], stage_name: str) -> dict[Path, str]:
|
||||
out: dict[Path, str] = {}
|
||||
# Служебный файл здесь не заводится: его пишет `write_config` по живому
|
||||
# файлу — версию двигает построчно, ключи дописывает, чужого не затирает.
|
||||
@@ -2372,9 +2514,7 @@ def init_files(lay: Layout, sections: list[str], stage_name: str,
|
||||
"# Беклог\n\n"
|
||||
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
|
||||
"+ строка здесь. Ведётся скиллом `av-dev:task-track`.\n\n"
|
||||
f"Стадия проекта — **{STAGE_RU[stage_name]}** (`[tasks] {STAGE_KEY} ="
|
||||
f' "{stage_name}"`).\n'
|
||||
+ BACKLOG_ORDER[stage_name] + "\n\n"
|
||||
+ "\n".join(stage_block(stage_name)) + "\n\n"
|
||||
f"Одно место в очереди назначено не человеком, а типом: сырьё"
|
||||
f" (`{RESEARCH}`\nбез раздела «{lay.cfg['question_heading']}») стоит в"
|
||||
" конце секции — его не берут.\n\n"
|
||||
@@ -2491,7 +2631,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||
|
||||
lay.items.mkdir(parents=True, exist_ok=True)
|
||||
plan = Plan()
|
||||
for path, text in init_files(lay, sections, stage_name, cfg).items():
|
||||
for path, text in init_files(lay, sections, stage_name).items():
|
||||
plan.file(path, text)
|
||||
plan.commit()
|
||||
said = write_config(project, cfg)
|
||||
@@ -2507,17 +2647,17 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||
# --- stage: смена стадии проекта ---
|
||||
|
||||
def cmd_stage(lay: Layout, a: argparse.Namespace) -> int:
|
||||
"""Показать стадию или сменить её.
|
||||
"""Показать стадию, объявить её впервые или сменить.
|
||||
|
||||
Смена — событие, а не настройка: она переворачивает смысл порядка строк
|
||||
(зависимость ↔ важность) и состав секций. Записывается она ключом конфига,
|
||||
и датой ей служит коммит — отдельного журнала для одной строки заводить не
|
||||
стоит.
|
||||
**Объявление и смена — разные операции, и различает их не флаг, а факт:**
|
||||
была ли стадия названа раньше. Объявление беклога не трогает вовсе — оно
|
||||
называет то, что уже верно, и переразметить при этом чужие полки значило бы
|
||||
подменить ответ на вопрос вопросом о нём. Смена трогает шапку и конфиг, а
|
||||
состав секций — **только если её об этом попросили** `--sections`.
|
||||
|
||||
Остаток беклога переезжает в первую новую секцию **целиком и молча не
|
||||
остаётся**: строки, писавшиеся как план, на доработке продолжают жить
|
||||
задачами, но порядок их с этого момента значит другое, и об этом говорится
|
||||
вслух.
|
||||
Слить полки сам скрипт не берётся ни в одном из случаев (решение Р240): в
|
||||
каком порядке пойдут строки слитых полок, знает человек. Отсюда и отказ на
|
||||
стройке при нескольких секциях — с названным выходом, а не глухой.
|
||||
"""
|
||||
if a.to is None:
|
||||
print(f"стадия: {STAGE_RU.get(lay.stage, 'не объявлена')}"
|
||||
@@ -2528,54 +2668,107 @@ def cmd_stage(lay: Layout, a: argparse.Namespace) -> int:
|
||||
|
||||
want = a.to.strip().lower()
|
||||
if want == lay.stage:
|
||||
raise Usage(f"стадия уже «{want}» — менять нечего")
|
||||
sections = uniq_sections(a.sections or DEFAULT_SECTIONS[want])
|
||||
if not sections:
|
||||
raise Usage("пустой список секций")
|
||||
if want == BUILD and len(sections) > 1:
|
||||
raise Usage(f"на стройке секция одна, а названо {len(sections)}"
|
||||
f" ({', '.join(sections)})")
|
||||
raise Usage(f"стадия уже «{STAGE_RU[want]}» ({want}) — менять нечего")
|
||||
declaring = lay.stage not in STAGES
|
||||
|
||||
lines = read_lines(lay.index("backlog"))
|
||||
heads = section_headers(lines)
|
||||
# Тело каждой секции — всё, что под её заголовком: и строки-пункты, и проза.
|
||||
# Собирается оно в первую новую секцию в прежнем порядке секций: другого
|
||||
# порядка машина не знает, а выдумать его значило бы переставить чужую
|
||||
# очередь.
|
||||
head = lines[:heads[0][0]] if heads else lines
|
||||
body: list[str] = []
|
||||
for k, (i, _) in enumerate(heads):
|
||||
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
|
||||
body += lines[i + 1:end]
|
||||
moved = sum(1 for line in body if INDEX_ENTRY.match(line))
|
||||
if not heads:
|
||||
raise Usage(f"в {lay.name('backlog')} нет ни одной секции — стадию"
|
||||
f" объявлять не над чем. Заведи секцию заголовком «## …»"
|
||||
f" и повтори")
|
||||
current = [name for _, name in heads]
|
||||
|
||||
out = [*head]
|
||||
for k, s in enumerate(sections):
|
||||
out.append(f"## {s}")
|
||||
if k == 0:
|
||||
out += body
|
||||
if a.sections is not None and declaring:
|
||||
raise Usage("--sections при объявлении стадии не принимается: объявление"
|
||||
" называет то, что уже верно, и беклог не переразмечает."
|
||||
" Секции меняет `move <слаг> --section <секция> --reason …`")
|
||||
sections = uniq_sections(a.sections) if a.sections is not None else current
|
||||
if not sections:
|
||||
raise Usage("пустой список секций")
|
||||
if want == BUILD and len(sections) > 1:
|
||||
raise Usage(
|
||||
f"на стройке беклог — один список от базы к деталям, а секций"
|
||||
f" {len(sections)}: {', '.join(sections)}. Слить их машина не берётся —"
|
||||
f" порядок строк в слитом списке знает только человек. Либо слей сам"
|
||||
f" (`move <слаг> --section <куда> --reason …`) и повтори, либо назови"
|
||||
f" итоговую секцию явно: `stage {BUILD} --sections <имя>`"
|
||||
+ (" (при объявлении стадии этот флаг не принимается —"
|
||||
" сперва слей руками)" if declaring else ""))
|
||||
|
||||
merge = sections != current
|
||||
plan = Plan()
|
||||
plan.index(lay, "backlog", out)
|
||||
# Категория в файлах — производна от заголовка индекса, и разъехаться ей
|
||||
# нельзя: `check` назовёт это дрейфом на первой же записи.
|
||||
retyped = 0
|
||||
for task in tasks_of(lay).values():
|
||||
if task["section_raw"] == sections[0]:
|
||||
continue
|
||||
text = meta_updated(task["path"], section=sections[0],
|
||||
rtype=task["type"] or None)
|
||||
if text is None:
|
||||
continue
|
||||
plan.file(task["path"], text)
|
||||
retyped += 1
|
||||
plan.commit()
|
||||
conf.set_section_key(lay.project, "tasks", STAGE_KEY, want)
|
||||
skipped: list[str] = []
|
||||
moved, retyped = 0, 0
|
||||
|
||||
was = STAGE_RU.get(lay.stage, "не объявлена")
|
||||
print(f"стадия: {was} → {STAGE_RU[want]}")
|
||||
print(f" секции беклога: {', '.join(sections)};"
|
||||
f" перенесено строк {moved}, поправлена «{PLACE_KEY}» у {retyped} файлов")
|
||||
if want == SUPPORT:
|
||||
if merge:
|
||||
# Тело каждой секции — всё, что под её заголовком: и строки-пункты, и
|
||||
# проза. Собирается оно в первую новую секцию в прежнем порядке секций:
|
||||
# другого порядка машина не знает, а выдумать его значило бы переставить
|
||||
# чужую очередь.
|
||||
head = lines[:heads[0][0]]
|
||||
body: list[str] = []
|
||||
for k, (i, _) in enumerate(heads):
|
||||
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
|
||||
body += lines[i + 1:end]
|
||||
moved = sum(1 for line in body if INDEX_ENTRY.match(line))
|
||||
lines = [*head]
|
||||
for k, s in enumerate(sections):
|
||||
lines.append(f"## {s}")
|
||||
if k == 0:
|
||||
lines += body
|
||||
# Категория в файлах производна от заголовка индекса, и разъехаться ей
|
||||
# нельзя: `check` назовёт это дрейфом на первой же записи.
|
||||
for name, task in tasks_of(lay).items():
|
||||
if task["section_raw"] == sections[0]:
|
||||
continue
|
||||
text = meta_updated(task["path"], section=sections[0],
|
||||
rtype=task["type"] or None)
|
||||
if text is None:
|
||||
skipped.append(name) # мета не пересобирается — скажем вслух
|
||||
continue
|
||||
plan.file(task["path"], text)
|
||||
retyped += 1
|
||||
|
||||
# Шапка объявляет стадию, и читают о смысле порядка строк именно её. Не
|
||||
# переписать её значило бы оставить в индексе прямое враньё.
|
||||
span = stage_block_span(lines)
|
||||
if span is not None:
|
||||
lines[span[0]:span[1]] = stage_block(want)
|
||||
# Место сырья производно от типа, и слияние секций его нарушает: сырьё из
|
||||
# второй полки оказывается в середине списка. Без этого шага переход
|
||||
# оставлял бы каталог красным на ровном месте.
|
||||
plan.index(lay, "backlog", raw_last(lines, {n for n, t in tasks_of(lay).items()
|
||||
if raw_research(lay, t)}))
|
||||
plan.commit()
|
||||
|
||||
# Конфиг: файл есть — правим ключ, файла нет — заводим скелетом. Иначе
|
||||
# объявление стадии в проекте без `.av-dev.toml` рождало бы конфиг без
|
||||
# версии раскладки, то есть меняло один отказ `check` на другой.
|
||||
if (lay.project / CONFIG_NAME).is_file():
|
||||
conf.set_section_key(lay.project, "tasks", STAGE_KEY, want)
|
||||
said = [f"{CONFIG_NAME}: [tasks] {STAGE_KEY} = «{want}»"]
|
||||
else:
|
||||
said = write_config(lay.project, {STAGE_KEY: want})
|
||||
|
||||
print(f"стадия: {'объявлена' if declaring else STAGE_RU.get(lay.stage, '—') + ' →'}"
|
||||
f" {STAGE_RU[want]}")
|
||||
for line in said:
|
||||
print(f" {line}")
|
||||
if merge:
|
||||
print(f" секции слиты в «{sections[0]}»: перенесено строк {moved},"
|
||||
f" поправлена «{PLACE_KEY}» у {retyped} файлов")
|
||||
else:
|
||||
print(f" секции беклога не тронуты: {', '.join(sections)}")
|
||||
if span is None:
|
||||
print(f" шапка {lay.name('backlog')} не размечена ({STAGE_OPEN}) —"
|
||||
f" абзац про стадию перепиши сам: он называет прежнюю")
|
||||
for name in skipped:
|
||||
print(f" НЕ ТРОНУТ {name}: мета не пересобирается (нет поля"
|
||||
f" **{PLACE_KEY}:**) — «{sections[0]}» проставь сам")
|
||||
if declaring:
|
||||
print(" беклог не тронут: объявление называет то, что уже верно")
|
||||
elif want == SUPPORT:
|
||||
print(" порядок строк с этого момента значит важность, а не зависимость:"
|
||||
" прежний план шёл по зависимости, и как очередь он не расставлен."
|
||||
" Первый заход — груминг (скилл av-dev:task-groom)")
|
||||
@@ -2931,10 +3124,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
# Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и
|
||||
# сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен.
|
||||
# Имён частей здесь нет — адаптация раскладывает всё по умолчаниям.
|
||||
for path, text in init_files(lay, sections, stage_name, {}).items():
|
||||
skeleton = init_files(lay, sections, stage_name)
|
||||
for path, text in skeleton.items():
|
||||
wr.file(path, text)
|
||||
backlog_lines = init_files(lay, sections, stage_name,
|
||||
{})[lay.index("backlog")].splitlines()
|
||||
backlog_lines = skeleton[lay.index("backlog")].splitlines()
|
||||
|
||||
renames: dict[str, str] = {}
|
||||
for it in pl.get("items", []):
|
||||
@@ -2968,7 +3161,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
entry_line(lay, title, slug, it.get("why", "")))
|
||||
|
||||
if pl.get("rejected"):
|
||||
head = init_files(lay, sections, stage_name, {})[lay.index("rejected")]
|
||||
head = skeleton[lay.index("rejected")]
|
||||
body = []
|
||||
for line in pl["rejected"]:
|
||||
line = re.sub(r"Был приоритет:", "Была секция:", line)
|
||||
@@ -2979,8 +3172,14 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
|
||||
|
||||
# Через `index`, а не `file`: отбивка секций живёт на записи индекса, и
|
||||
# каталог, собранный в обход неё, встречал бы человека ошибкой `check`
|
||||
# на первом же прогоне.
|
||||
wr.index(lay, "backlog", backlog_lines)
|
||||
# на первом же прогоне. По той же причине здесь же место сырья: карта могла
|
||||
# положить разведку без «Вопроса» в середину списка, и `check --fix` потом
|
||||
# переставил бы её — то есть тронул бы порядок, который человек подтвердил.
|
||||
raw_now = {f"{it.get('slug') or it['old_slug']}.md" for it in pl.get("items", [])
|
||||
if (it.get("type") or "").lower() == RESEARCH
|
||||
and not re.search(rf"^##\s+{re.escape(lay.cfg['question_heading'])}\s*$",
|
||||
it.get("body", ""), flags=re.M | re.I)}
|
||||
wr.index(lay, "backlog", raw_last(backlog_lines, raw_now))
|
||||
|
||||
if a.dry_run:
|
||||
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
|
||||
@@ -3081,7 +3280,7 @@ def main() -> int:
|
||||
p.add_argument("--rm-tag", dest="rm_tag")
|
||||
p.add_argument("--dir")
|
||||
|
||||
p = sub.add_parser("move", help="переставить строку: место в очереди или другая секция")
|
||||
p = sub.add_parser("move", help="переставить строку: место в списке или другая секция")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--section", help="другая категория беклога;"
|
||||
" без него — текущая секция записи")
|
||||
|
||||
Reference in New Issue
Block a user