канон 4: секция «Сопровождение», «Готово» вниз, порядок закреплён

healthlog уже переехал на канон 3, а переименование секции я внёс
правкой записи версии 3 задним числом — то есть переписал текст, по
которому он ехал. Посылка «ни один проект на каноне 3 не стоит» была
ложной, решение ШШШ отменено.

Запись версии — черновик ровно до первого переехавшего проекта. После
этого она история, и любое изменение канона заводит новую версию, даже
если меняется одно слово. Проверять дёшево: grep '"canon"' по живым
проектам. Дорого обратное — проект, повышенный по тексту, которого
больше не существует, невоспроизводим.

Запись версии 3 восстановлена дословно (Разработка | Tooling),
переименование уехало в версию 4. jellybit, стоящий на каноне 2,
прочтёт обе записи подряд и заведёт Разработка, чтобы через шаг
переименовать; в шаг версии 3 добавлена оговорка «едешь сразу на 4 —
заводи Готово последней и не переставляй дважды».

«Готово» переехало вниз, и порядок секций стал каноническим.
Достигнутое копится: через год этой секции больше, чем всех остальных
вместе, и стоя первой она отодвигает за экран то, ради чего роадмап
открывают чаще всего. Порядок проверяет roadmap_lint, переставляет
check --fix — вместе с содержимым секций, потому что двигать десяток
строк руками это работа, на которой ошибаются. Чужую секцию
перестановка не трогает вовсе: её место в порядке неизвестно.

Индексы позиций считаются из самого кортежа: ACHIEVED был 0 и стал 3,
хардкод пережил бы перестановку молча и сломал бы close.

Обкатка нашла два дефекта оформления, оба порождённые самой
перестановкой. Отбивка нужна и перед заголовком — сдвиг блоков ставит
два заголовка вплотную. Удаление строки индекса оставляет две пустые
подряд, и пустоты копятся. Проверка оформления теперь сверяется с самим
нормализатором, а не своим набором условий: два описания одного правила
разъедутся, и check начнёт молчать о том, что --fix правит.

CANON_VERSION = 4 в docs.py, примеры .pm.json в canon.md и skeletons.md.

DECISIONS тема 26 (ЭЭЭ, ЮЮЮ, ЯЯЯ, следствия 98–100).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-04 20:29:15 +03:00
co-authored by Claude Opus 5
parent d7e9740c73
commit 069205ac69
9 changed files with 240 additions and 56 deletions
+17 -11
View File
@@ -67,30 +67,36 @@ docs/tasks/
подо что берут.** Цель в спринт взять нельзя, поэтому в списке берущихся ей не
место.
**Четыре секции роадмапа, и первая отвечает на половину вопроса:**
**Четыре секции роадмапа, и последняя отвечает на половину вопроса:**
| Секция | Англ. | Что в ней |
| --- | --- | --- |
| `Готово` | `Done` | достигнутые цели — строкой с датой, **без ссылки на файл**: файл удалён, поведение живёт в спеках |
| `Запланировано` | `Planned` | очередь значима и обосновывается прозой рядом |
| `Направления` | `Directions` | очереди нет, тянутся долго |
| `Сопровождение` | `Operations` | чем держат проект: инструмент, процесс, эксплуатация — не возможности приложения, и потому отдельно |
| `Готово` | `Done` | достигнутые цели — строкой с датой, **без ссылки на файл**: файл удалён, поведение живёт в спеках |
**Порядок тоже канонический, и `Готово` стоит последним не из скромности.**
Достигнутое **копится**: через год этой секции больше, чем всех остальных
вместе. Стоя первой, она отодвигает за экран ровно то, ради чего роадмап
открывают чаще всего, — что делается сейчас и что дальше. Порядок проверяет
`check`, переставляет `check --fix`.
**Секции роадмапа канонические, секции беклога — нет**, и разница не в любви к
единообразию. У каждой секции роадмапа свой смысл, в первую пишет сам `close`, и
единообразию. У каждой секции роадмапа свой смысл, в достигнутое пишет сам `close`, и
роадмап, названный по-своему, читался бы только своим автором. Секции беклога
(`Ядро`, `Инфра`) смысла не несут — это полки, и остаются делом проекта.
Отсюда три правила, которые проверяет `tasks.py check`: **состав закреплён**
Отсюда четыре правила, которые проверяет `tasks.py check`: **состав закреплён**
(чужая секция — ошибка, а не вольность), **все четыре обязаны быть** (нет
секции — нет ответа на её часть вопроса), **язык один на весь индекс**.
`--roadmap-sections` у `init` нет: выбирать нечего.
секции — нет ответа на её часть вопроса), **язык один на весь индекс**, **порядок
канонический**. `--roadmap-sections` у `init` нет: выбирать нечего.
**Заголовок секции — с прописной, после него пустая строка.** Во всех индексах
одинаково, включая секции беклога, которые проект называет сам. Написание
канонических секций правит `check --fix` (заодно и ссылку на секцию в мете
файлов: имя секции принадлежит заголовку индекса, файл на неё только
ссылается); отбивку он ставит везде.
**Заголовок секции отбит пустой строкой с обеих сторон и написан с прописной.**
Во всех индексах одинаково, включая секции беклога, которые проект называет сам.
Написание канонических секций правит `check --fix` (заодно и ссылку на секцию в
мете файлов: имя секции принадлежит заголовку индекса, файл на неё только
ссылается); отбивку и порядок он правит везде.
Оговорка про `Сопровождение`: слово `окружение` сюда не годится — в
`architecture.md` оно уже значит боевое окружение приложения, и одно слово в двух
@@ -225,7 +225,7 @@
| Файл | Что отвечает | Секции |
| --- | --- | --- |
| `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические: `Готово`, `Запланировано`, `Направления`, `Сопровождение` (англ. `Done`, `Planned`, `Directions`, `Operations`) |
| `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические и в этом порядке: `Запланировано`, `Направления`, `Сопровождение`, `Готово` (англ. `Planned`, `Directions`, `Operations`, `Done`) |
| `BACKLOG.md` | что **можно взять** — только задачи | секции проекта (по умолчанию Ядро/Инфра) |
| `SPRINT.md` | какая цель и какой набор под неё | одна: «Набор» |
| `REJECTED.md` | что ушло без реализации и почему | — |
@@ -250,11 +250,13 @@
строки не той формы, что у прочих индексов: дата, слаг, заголовок — как в
`REJECTED.md`, и по той же причине (файла уже нет, ссылаться некуда).
**Секции роадмапа закреплены** — состав, полнота и единство языка проверяются
`check`; секции беклога проект называет сам. Почему так — SKILL.md.
**Секции роадмапа закреплены** — состав, полнота, единство языка и **порядок**
проверяются `check`; секции беклога проект называет сам. Почему так — SKILL.md.
Порядок закреплён потому, что `Готово` копится: стоя первым, достигнутое
отодвигает за экран то, ради чего роадмап открывают чаще всего.
**Заголовок секции пишется с прописной, и после него идёт пустая строка** — во
всех индексах, включая секции беклога, имена которых выбирает проект. Написание
**Заголовок секции пишется с прописной и отбивается пустой строкой с обеих
сторон** — во всех индексах, включая секции беклога, имена которых выбирает проект. Написание
канонических секций и отбивку правит `check --fix`; он же сводит написание
секции в мете файла с заголовком индекса — **имя секции принадлежит заголовку**,
файл на неё лишь ссылается, и принадлежность сверяется по нижнему регистру.
+79 -22
View File
@@ -13,9 +13,9 @@ av-dev, и подгоняется под него проект. Имена вн
docs/tasks/
items/ задачи и цели файлами, <slug>.md
ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет.
Секции канонические: Готово | Запланировано |
Направления | Сопровождение (или Done | Planned |
Directions | Operations — один язык на весь индекс)
Секции канонические и в этом порядке: Запланировано |
Направления | Сопровождение | Готово (или Planned |
Directions | Operations | Done — один язык на индекс)
BACKLOG.md что можно взять — только задачи, целей здесь нет
SPRINT.md текущий спринт: цель, набор, дата, слаг
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
@@ -131,7 +131,7 @@ DEFAULT_SECTIONS = "Ядро,Инфра"
# Секции роадмапа **канонические**, в отличие от секций беклога. Причина не в
# любви к единообразию: у каждой своя семантика — достигнутое, очередь, долгие
# направления, работа над инструментом, — в первую пишет сам `close`, и роадмап,
# направления, работа по сопровождению, — в достигнутое пишет сам `close`, и роадмап,
# названный по-своему, читался бы только своим автором. Секции беклога семантики
# не несут, это полки, и остаются делом проекта.
#
@@ -139,13 +139,17 @@ DEFAULT_SECTIONS = "Ядро,Инфра"
# индекс** — вперемешку это дрейф, который check называет вслух. Сверка везде
# идёт по нижнему регистру, а пишется — как здесь: заголовок предложением, с
# прописной.
# Порядок значим и проверяется: достигнутое **копится**, и стоя первым оно со
# временем отодвигает за экран всё, ради чего роадмап открывают.
ROADMAP_SECTIONS = (
("Готово", "Done"), # достигнутое: что приложение уже умеет
("Запланировано", "Planned"), # очередь значима, обоснована прозой
("Направления", "Directions"), # очереди нет, тянутся долго
("Сопровождение", "Operations"), # чем держат проект, а не что умеет приложение
("Готово", "Done"), # достигнутое: что приложение уже умеет
)
ACHIEVED, PLANNED = 0, 1 # индексы в ROADMAP_SECTIONS
# Позиции — из самого кортежа, а не числами: переставили секцию — индексы
# переехали сами.
PLANNED, DIRECTIONS, OPERATIONS, ACHIEVED = range(len(ROADMAP_SECTIONS))
DEFAULT_ROADMAP_SECTIONS = ",".join(ru for ru, _ in ROADMAP_SECTIONS)
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
@@ -568,17 +572,28 @@ def action_title(title: str) -> bool:
def spaced_sections(lines: list[str]) -> list[str]:
"""Отбивка после заголовка секции. Заголовок, пустая строка, потом
содержимое — во всех индексах одинаково.
"""Отбивка вокруг заголовка секции: пустая строка перед ним и после него.
Живёт на записи, а не на вставке: через `Plan.index` проходит **каждая**
запись индекса, и чинить отбивку в каждом месте вставки значило бы
полагаться на то, что ни одно из них не забыли."""
полагаться на то, что ни одно из них не забыли. Перед заголовком — не
педантизм: перестановка секций двигает целые блоки, и два заголовка легко
оказываются вплотную друг к другу.
Заодно схлопывает подряд идущие пустые строки: удаление строки индекса
оставляет после себя две, и без этого шага пустоты копятся."""
out: list[str] = []
for i, line in enumerate(lines):
if SECTION.match(line):
if out and out[-1].strip():
out.append("")
out.append(line)
if i + 1 < len(lines) and lines[i + 1].strip():
out.append("")
continue
if not line.strip() and out and not out[-1].strip():
continue
out.append(line)
if SECTION.match(line) and i + 1 < len(lines) and lines[i + 1].strip():
out.append("")
return out
@@ -591,9 +606,6 @@ def index_lint(lines: list[str], label: str) -> list[str]:
for num, line in enumerate(lines, 1):
if (m := SECTION.match(line)):
section = m.group(1)
if num < len(lines) and lines[num].strip():
errors.append(f"{label}:{num}: после заголовка «{section}» нет"
f" пустой строки; починит `check --fix`")
continue
if not line.startswith("- ["):
continue
@@ -610,6 +622,13 @@ def index_lint(lines: list[str], label: str) -> list[str]:
f" (первая — строка {seen[target]})")
else:
seen[target] = num
# Оформление сверяется **самим нормализатором**, а не своим набором условий:
# два описания одного правила разъедутся, и `check` начнёт молчать о том,
# что `--fix` правит (или наоборот).
if spaced_sections(lines) != lines:
errors.append(f"{label}: оформление секций — заголовок отбивается пустой"
f" строкой с обеих сторон, подряд идущих пустых строк не"
f" бывает; починит `check --fix`")
return errors
@@ -1218,8 +1237,34 @@ def canon_section(lines: list[str], which: int) -> str | None:
return None
def roadmap_ordered(lines: list[str]) -> list[str]:
"""Секции роадмапа, переставленные в канонический порядок вместе с их
содержимым. Преамбула остаётся на месте.
Чужая секция останавливает перестановку целиком: её место в порядке
неизвестно, а угадывать значило бы переложить чьи-то строки наугад. О ней
скажет `roadmap_lint`, и человек решит сам."""
heads = [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))]
if not heads:
return lines
order = {n.lower(): i for i, pair in enumerate(ROADMAP_SECTIONS) for n in pair}
if any(name.lower() not in order for _, name in heads):
return lines
blocks = []
for k, (i, name) in enumerate(heads):
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
blocks.append((order[name.lower()], lines[i:end]))
if [rank for rank, _ in blocks] == sorted(rank for rank, _ in blocks):
return lines
out = lines[:heads[0][0]]
for _, block in sorted(blocks, key=lambda b: b[0]):
out = out + block
return out
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] = {}
@@ -1251,6 +1296,12 @@ def roadmap_lint(lines: list[str], label: str) -> list[str]:
f" целиком — отсутствующая секция это отсутствующий ответ")
if len(langs) > 1:
errors.append(f"{label}: секции вперемешку на двух языках — выбери один")
if not missing and roadmap_ordered(lines) != lines:
canon = ", ".join(pair[0] for pair in ROADMAP_SECTIONS)
errors.append(f"{label}: секции не в каноническом порядке ({canon}) —"
f" достигнутое копится и потому стоит последним, иначе оно"
f" отодвигает за экран то, ради чего роадмап открывают;"
f" переставит `check --fix`")
return errors
@@ -2199,6 +2250,11 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
lines[j] = f"## {want}"
fixed.append(f"{lay.name(kind)}: секция «{m.group(1)}» → «{want}»")
dirty.add(kind)
if (moved := roadmap_ordered(lines)) != lines:
lines[:] = moved
fixed.append(f"{lay.name(kind)}: секции переставлены в канонический"
f" порядок ({', '.join(p[0] for p in ROADMAP_SECTIONS)})")
dirty.add(kind)
if spaced_sections(lines) != lines:
fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций")
dirty.add(kind)
@@ -2272,16 +2328,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"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n"
f"- **{ROADMAP_SECTIONS[DIRECTIONS][0]}** — очереди нет, тянутся долго;\n"
f"- **{ROADMAP_SECTIONS[OPERATIONS][0]}** — чем держат проект: инструмент,\n"
" процесс, эксплуатация. Не возможности приложения, и отдельно —\n"
" чтобы не читаться как обещание продукта;\n"
f"- **{ROADMAP_SECTIONS[ACHIEVED][0]}** — достигнутое: строку пишет\n"
" `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n"
" файл удаляется, поведение живёт в спеках;\n"
f"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n"
f"- **{ROADMAP_SECTIONS[2][0]}** — очереди нет, тянутся долго;\n"
f"- **{ROADMAP_SECTIONS[3][0]}** — чем держат проект: инструмент,\n"
" процесс, эксплуатация. Не возможности приложения, и отдельно —\n"
" чтобы не читаться как обещание продукта.\n\n"
" файл удаляется, поведение живёт в спеках. Стоит последней: копится.\n\n"
"Секции **канонические** и переименованию проектом не подлежат:\n"
"у каждой свой смысл, и в первую пишет сам `close`. Английский\n"
"у каждой свой смысл, и в достигнутое пишет сам `close`. Порядок\n"
"тоже канонический. Английский\n"
f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)},"
" один язык на весь\nиндекс.\n\n"
+ "".join(f"## {s}\n\n" for s in roadmap_sections))