@@ -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 = " другая категория беклога; "
" без него — текущая секция записи " )