секции роадмапа названы и закреплены линтером
готово | запланировано | направления | разработка, англ. done | planned | directions | tooling. Из четырёх предложенных имён отвергнуто одно, и по проверяемой причине: «окружение» уже занято — в architecture.md это боевое окружение приложения, «где работает, что рядом, кто перезапускает», и одно слово в двух смыслах развело бы документы канона. Секции роадмапа стали каноническими, в отличие от секций беклога, и разница выведена, а не назначена: у каждой секции роадмапа своя семантика, в первую пишет сам close, и роадмап, названный по-своему, читался бы только своим автором. Секции беклога — полки, смысла не несут, остаются делом проекта. check проверяет три вещи: состав закреплён (чужая секция — ошибка), все четыре обязаны быть, язык один на весь индекс. Проверено на том случае, ради которого правило и заводилось: «Что уже пройдено», которую healthlog вёл руками, теперь называется ошибкой поимённо. Оба языка прогнаны вживую, включая close в английский роадмап. Ключа tasks.achieved_section не появилось — секция достигнутого опознаётся по каноническому имени в любом из языков; --roadmap-sections у init упразднён, выбирать больше нечего. Названные вслух компромиссы: «готово» слегка тянет в трекерную рамку «состояние работы», тогда как секция про возможность — перевесила читаемость; цель в «запланировано» может быть уже наполовину построена, это очередь, а не «не начато», «в работе» живёт в SPRINT.md. DECISIONS 19: ГГГ переписан, добавлен ДДД, следствие 78 заменено. TODO 7 закрыт. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,10 @@ av-dev, и подгоняется под него проект. Имена вн
|
||||
|
||||
docs/tasks/
|
||||
items/ задачи и цели файлами, <slug>.md
|
||||
ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет
|
||||
ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет.
|
||||
Секции канонические: готово | запланировано |
|
||||
направления | разработка (или done | planned |
|
||||
directions | tooling — один язык на весь индекс)
|
||||
BACKLOG.md что можно взять — только задачи, целей здесь нет
|
||||
SPRINT.md текущий спринт: цель, набор, дата, слаг
|
||||
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
|
||||
@@ -46,7 +49,7 @@ av-dev, и подгоняется под него проект. Имена вн
|
||||
разъедется на синонимах, и отбор по роду перестанет отвечать.
|
||||
|
||||
Использование:
|
||||
tasks.py init [--dir DIR] [--sections …] [--roadmap-sections …] [--items …]
|
||||
tasks.py init [--dir DIR] [--sections …] [--items …] [--backlog …] …
|
||||
tasks.py check [--dir DIR] [--fix]
|
||||
tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b]
|
||||
[--goal S] [--kind K] [--index backlog|sprint|roadmap|all]
|
||||
@@ -114,9 +117,6 @@ DEFAULTS = {
|
||||
"sprint": "SPRINT.md",
|
||||
"rejected": "REJECTED.md",
|
||||
"sprint_section": "Набор",
|
||||
# Секция роадмапа, куда переезжает достигнутая цель. Скрипт обязан знать её
|
||||
# имя: остальные секции он берёт из заголовков как есть, а в эту пишет сам.
|
||||
"achieved_section": "умеет",
|
||||
"criteria_heading": "Критерии приёмки",
|
||||
"surface_heading": "Затрагивает",
|
||||
"questions_heading": "Вопросы",
|
||||
@@ -128,7 +128,23 @@ DEFAULTS = {
|
||||
PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected")
|
||||
|
||||
DEFAULT_SECTIONS = "ядро,инфра"
|
||||
DEFAULT_ROADMAP_SECTIONS = "умеет,строим,направления,станок"
|
||||
|
||||
# Секции роадмапа **канонические**, в отличие от секций беклога. Причина не в
|
||||
# любви к единообразию: у каждой своя семантика — достигнутое, очередь, долгие
|
||||
# направления, работа над инструментом, — в первую пишет сам `close`, и роадмап,
|
||||
# названный по-своему, читался бы только своим автором. Секции беклога семантики
|
||||
# не несут, это полки, и остаются делом проекта.
|
||||
#
|
||||
# Пара на секцию: русское имя и английское. Проект держит **один язык на весь
|
||||
# индекс** — вперемешку это дрейф, который check называет вслух.
|
||||
ROADMAP_SECTIONS = (
|
||||
("готово", "done"), # достигнутое: что приложение уже умеет
|
||||
("запланировано", "planned"), # очередь значима, обоснована прозой
|
||||
("направления", "directions"), # очереди нет, тянутся долго
|
||||
("разработка", "tooling"), # инструмент и процесс, не про приложение
|
||||
)
|
||||
ACHIEVED, PLANNED = 0, 1 # индексы в ROADMAP_SECTIONS
|
||||
DEFAULT_ROADMAP_SECTIONS = ",".join(ru for ru, _ in ROADMAP_SECTIONS)
|
||||
|
||||
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
|
||||
# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в
|
||||
@@ -934,7 +950,10 @@ def check(lay: Layout, fix: bool = False) -> int:
|
||||
if name not in tasks:
|
||||
errors.append(f"{label[kind]}:{entry['line']}: ссылка на несуществующий"
|
||||
f" {lay.cfg['items']}/{name}")
|
||||
errors += index_lint(read_lines(lay.index(kind)), label[kind])
|
||||
lines = read_lines(lay.index(kind))
|
||||
errors += index_lint(lines, label[kind])
|
||||
if kind == "roadmap":
|
||||
errors += roadmap_lint(lines, label[kind])
|
||||
|
||||
if goal_of_sprint and goal_of_sprint + ".md" not in tasks:
|
||||
errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена"
|
||||
@@ -1104,6 +1123,52 @@ def section_headers(lines: list[str]) -> list[tuple[int, str]]:
|
||||
return [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))]
|
||||
|
||||
|
||||
def canon_section(lines: list[str], which: int) -> str | None:
|
||||
"""Имя канонической секции роадмапа **как она названа в этом индексе**.
|
||||
|
||||
Проект пишет либо русские имена, либо английские; скрипт принимает оба и
|
||||
возвращает то, что нашёл, — иначе `insert_entry` промахнётся мимо секции,
|
||||
которая есть, но названа на другом языке.
|
||||
"""
|
||||
names = {n.lower() for n in ROADMAP_SECTIONS[which]}
|
||||
for _, section in section_headers(lines):
|
||||
if section.lower() in names:
|
||||
return section
|
||||
return None
|
||||
|
||||
|
||||
def roadmap_lint(lines: list[str], label: str) -> list[str]:
|
||||
"""Секции роадмапа: все канонические, все на месте, все на одном языке."""
|
||||
known = {n.lower(): i for i, pair in enumerate(ROADMAP_SECTIONS) for n in pair}
|
||||
errors: list[str] = []
|
||||
seen: dict[int, str] = {}
|
||||
langs: set[int] = set()
|
||||
for _, section in section_headers(lines):
|
||||
i = known.get(section.lower())
|
||||
if i is None:
|
||||
ru = ", ".join(pair[0] for pair in ROADMAP_SECTIONS)
|
||||
errors.append(f"{label}: секция «{section}» не из канона роадмапа"
|
||||
f" ({ru}). Секции роадмапа несут смысл и потому"
|
||||
f" закреплены; прозаический заголовок здесь — секция,"
|
||||
f" в которую может уехать цель")
|
||||
continue
|
||||
langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0] else 1)
|
||||
if i in seen:
|
||||
errors.append(f"{label}: секция «{section}» повторяет «{seen[i]}» —"
|
||||
f" это одна и та же секция на двух языках")
|
||||
else:
|
||||
seen[i] = section
|
||||
missing = [ROADMAP_SECTIONS[i][0] for i in range(len(ROADMAP_SECTIONS))
|
||||
if i not in seen]
|
||||
if missing:
|
||||
errors.append(f"{label}: нет секций: {', '.join(missing)}."
|
||||
f" Роадмап отвечает на «что умеет и чего не умеет»"
|
||||
f" целиком — отсутствующая секция это отсутствующий ответ")
|
||||
if len(langs) > 1:
|
||||
errors.append(f"{label}: секции вперемешку на двух языках — выбери один")
|
||||
return errors
|
||||
|
||||
|
||||
def find_section(lines: list[str], name: str) -> tuple[int | None, str]:
|
||||
for i, s in section_headers(lines):
|
||||
if s.lower() == (name or "").lower():
|
||||
@@ -1474,6 +1539,17 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def section_of_achieved(lines: list[str]) -> str:
|
||||
"""Секция достигнутого этого роадмапа. Нет её — отказ по существу: цель
|
||||
закрыть некуда, и молча удалить её значит стереть половину ответа."""
|
||||
name = canon_section(lines, ACHIEVED)
|
||||
if name is None:
|
||||
ru, en = ROADMAP_SECTIONS[ACHIEVED]
|
||||
raise Usage(f"в роадмапе нет секции достигнутого («{ru}» или «{en}») —"
|
||||
f" закрывать цель некуда. Заведи секцию и повтори")
|
||||
return name
|
||||
|
||||
|
||||
def is_achieved(task: dict, a: argparse.Namespace) -> bool:
|
||||
"""Цель, закрытая как достигнутая: `close --implemented` без причины."""
|
||||
return task["type"] == GOAL and not a.reason
|
||||
@@ -1528,20 +1604,22 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
|
||||
for kind_index, (lines, ei) in places.items():
|
||||
lines.pop(ei)
|
||||
if achieved is not None and kind_index == "roadmap":
|
||||
insert_entry(lines, lay.cfg["achieved_section"], achieved, first=True)
|
||||
insert_entry(lines, section_of_achieved(lines), achieved, first=True)
|
||||
achieved = None
|
||||
plan.index(lay, kind_index, lines)
|
||||
if achieved is not None: # строки в роадмапе не было — чиним
|
||||
lines = read_lines(lay.index("roadmap"))
|
||||
insert_entry(lines, lay.cfg["achieved_section"], achieved, first=True)
|
||||
insert_entry(lines, section_of_achieved(lines), achieved, first=True)
|
||||
plan.index(lay, "roadmap", lines)
|
||||
plan.delete(path)
|
||||
plan.commit()
|
||||
|
||||
if is_achieved(task, a):
|
||||
# Имя берём из индекса: проект мог назвать секции по-английски.
|
||||
where = (canon_section(read_lines(lay.index("roadmap")), ACHIEVED)
|
||||
or ROADMAP_SECTIONS[ACHIEVED][0])
|
||||
print(f"{a.slug}: цель достигнута — строка перенесена в"
|
||||
f" {lay.name('roadmap')}, секция «{lay.cfg['achieved_section']}»,"
|
||||
f" файл удалён")
|
||||
f" {lay.name('roadmap')}, секция «{where}», файл удалён")
|
||||
else:
|
||||
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
|
||||
if "sprint" in places and a.reason:
|
||||
@@ -1655,7 +1733,7 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
|
||||
for line in removed:
|
||||
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
|
||||
for line in unachieved:
|
||||
print(f" снята строка «{lay.cfg['achieved_section']}» в"
|
||||
print(f" снята строка достигнутого в"
|
||||
f" {lay.name('roadmap')}: {line.strip()}")
|
||||
print(" роадмап больше не утверждает, что приложение это умеет")
|
||||
print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее"
|
||||
@@ -2064,13 +2142,17 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
|
||||
f"Цель — возможность приложения, файл `[goal]` в `{lay.cfg['items']}/`; её\n"
|
||||
"задачи здесь **не перечисляются** — перечень даёт\n"
|
||||
"`tasks.py list --goal <слаг>`.\n\n"
|
||||
f"- **{lay.cfg['achieved_section']}** — достигнутое: строку пишет\n"
|
||||
f"- **{ROADMAP_SECTIONS[ACHIEVED][0]}** — достигнутое: строку пишет\n"
|
||||
" `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n"
|
||||
" файл удаляется, поведение живёт в спеках;\n"
|
||||
"- **строим** — очередь значима и обосновывается прозой;\n"
|
||||
"- **запланировано** — очередь значима и обосновывается прозой;\n"
|
||||
"- **направления** — очереди нет, тянутся долго;\n"
|
||||
"- **станок** — инструмент и процесс разработки, не возможности\n"
|
||||
" приложения. Отдельно, чтобы не смешиваться с ними.\n\n"
|
||||
"- **разработка** — инструмент и процесс, не возможности приложения.\n"
|
||||
" Отдельно, чтобы не читаться как обещание продукта.\n\n"
|
||||
"Секции **канонические** и переименованию проектом не подлежат:\n"
|
||||
"у каждой свой смысл, и в первую пишет сам `close`. Английский\n"
|
||||
"вариант — done | planned | directions | tooling, один язык на весь\n"
|
||||
"индекс.\n\n"
|
||||
+ "".join(f"## {s}\n\n" for s in roadmap_sections))
|
||||
out[lay.index("sprint")] = empty_sprint(lay)
|
||||
out[lay.index("rejected")] = (
|
||||
@@ -2100,8 +2182,9 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||
if lay.index("backlog").exists():
|
||||
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
|
||||
|
||||
sections, roadmap_sections = uniq_sections(a.sections), uniq_sections(a.roadmap_sections)
|
||||
if not sections or not roadmap_sections:
|
||||
sections = uniq_sections(a.sections)
|
||||
roadmap_sections = uniq_sections(DEFAULT_ROADMAP_SECTIONS)
|
||||
if not sections:
|
||||
raise Usage("пустой список секций")
|
||||
blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS]
|
||||
if blockers:
|
||||
@@ -2118,7 +2201,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int:
|
||||
plan.commit()
|
||||
print(f"каталог задач заведён: {root}")
|
||||
print(f" секции беклога: {', '.join(sections)};"
|
||||
f" части роадмапа: {', '.join(roadmap_sections)}")
|
||||
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
|
||||
if cfg:
|
||||
pm = (root / PM_CONFIG_REL).resolve()
|
||||
print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}")
|
||||
@@ -2260,17 +2343,6 @@ def scan_list_file(path: Path) -> dict:
|
||||
return found
|
||||
|
||||
|
||||
def first_open_section(raw: str) -> str:
|
||||
"""Секция, куда adopt кладёт выведенные цели: первая **не** достигнутая.
|
||||
|
||||
Первой в роадмапе идёт секция достигнутого, и класть в неё цель, выведенную
|
||||
из шага плана, значит объявить сделанным то, что ещё не начато.
|
||||
"""
|
||||
done = DEFAULTS["achieved_section"].lower()
|
||||
names = [s.strip() for s in raw.split(",") if s.strip()]
|
||||
return next((s for s in names if s.lower() != done), "строим")
|
||||
|
||||
|
||||
def cmd_adopt_scan(a: argparse.Namespace) -> int:
|
||||
sources = [Path(s) for s in a.sources]
|
||||
for s in sources:
|
||||
@@ -2286,7 +2358,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int:
|
||||
rejected += sc.get("rejected", [])
|
||||
unclassified += sc.get("unclassified", [])
|
||||
goals += [{"slug": "", "title": g["title"],
|
||||
"section": first_open_section(a.roadmap_sections),
|
||||
"section": ROADMAP_SECTIONS[PLANNED][0],
|
||||
"from": g["from"], "step": g.get("step"), "done": g.get("done"),
|
||||
"body": f"Выведена из шага «{g['title']}» ({g['from']})."
|
||||
+ ("\n\nШаг помечен закрытым — цель, скорее всего,"
|
||||
@@ -2320,7 +2392,7 @@ def cmd_adopt_scan(a: argparse.Namespace) -> int:
|
||||
"sources": [str(s) for s in sources],
|
||||
"path_map": path_map,
|
||||
"sections_backlog": uniq_sections(a.sections),
|
||||
"sections_roadmap": uniq_sections(a.roadmap_sections),
|
||||
"sections_roadmap": uniq_sections(DEFAULT_ROADMAP_SECTIONS),
|
||||
"section_map": {},
|
||||
"goals": goals,
|
||||
"items": items,
|
||||
@@ -2688,7 +2760,6 @@ def main() -> int:
|
||||
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
|
||||
p.add_argument("--dir")
|
||||
p.add_argument("--sections", default=DEFAULT_SECTIONS)
|
||||
p.add_argument("--roadmap-sections", dest="roadmap_sections", default=DEFAULT_ROADMAP_SECTIONS)
|
||||
p.add_argument("--items")
|
||||
p.add_argument("--backlog")
|
||||
p.add_argument("--roadmap")
|
||||
@@ -2702,7 +2773,7 @@ def main() -> int:
|
||||
s.add_argument("--target", default="docs/tasks")
|
||||
s.add_argument("--out", default="tasks-adopt-plan.json")
|
||||
s.add_argument("--sections", default=DEFAULT_SECTIONS)
|
||||
s.add_argument("--roadmap-sections", dest="roadmap_sections", default=DEFAULT_ROADMAP_SECTIONS)
|
||||
|
||||
s = asub.add_parser("apply", help="записать каталог по подтверждённой карте")
|
||||
s.add_argument("--plan", required=True)
|
||||
s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")
|
||||
|
||||
Reference in New Issue
Block a user