ревью по темам: документ проекта стал направлением проверки

Замечено при сверке документов канона с составом ступеней: три документа
остались без читателя ниже wide — security.md, database.md и adr/. Проект
поддерживал их, а на 90% задач не открывал никто. Причина оказалась не в
переезде проходов, а в том, как описан состав прогона.

Список тем нигде не был записан: он существовал побочным продуктом списка
проходов. Проход уезжал в верхнюю ступень — и тема уезжала с ним беззвучно.
Отчёт честно говорил «ops не запускался» и не говорил «эксплуатацию не смотрел
никто», а нужно второе. Теперь тема первична, проход вторичен — это правило 0
конвейера, а прогон описывается таблицей «тема → дом → глубина → кто закрывает»,
и таблица есть в каждом отчёте.

Тема есть документ, список открытый. Всё, что проект кладёт в docs/, становится
темой ревью; запретить нельзя, разрешения не надо. Не темы ровно две: docs/tasks/
и docs/review — настройка самого конвейера, слой над темами. Отсюда главное:
docs/ перестал быть документацией и стал конфигурацией конвейера. Проект
настраивает проверку тем, что пишет о себе, а не отдельным файлом настроек,
который разошёлся бы с документами. Ядро — requirements, autotests, conventions,
architecture, security, operations; всё сверх разбирает basics, потому что
именных проходов конечное число, а тем столько, сколько заведёт проект.

Тема живёт файлом или каталогом, на выбор проекта: docs/security.md и
docs/security/ — одно и то же. Прежде форма была задана поимённо и обосновать её
было нечем; заодно в TODO висел вопрос «а если architecture.md разрастётся».
Теперь ответ механический: разросся — стал каталогом с README.md, и это не смена
версии. Обе формы сразу — ошибка, docs.py её ловит.

Заведён review-scope, sonnet, стадия 0, до гейта: находит документы, выводит
темы, назначает глубины, выбирает ступень с обоснованием. Довод оказался сильнее
синхронизации документов — до сих пор профиль называл тот же оркестратор,
который написал код, то есть в точке выбора глубины проверки разведённости с
автором не было вовсе, а решала она под давлением «я почти закончил». Вызывающий
пайплайн профиль больше не передаёт. Поднять и понизить ступень разметчик вправе
одинаково, но обоснование обязательно всегда.

Sonnet ему хватает потому, что вывод устроен как список: каждый файл в docs/
обязан попасть в план темой или строкой «не тема, потому что», и план сверяется с
ls docs/ за секунду. Выбор ступени — суждение, но у него три независимых
корректора: отрицательный тест quick, правило «спорный случай вниз» и сигнал
basics о заниженной ступени.

Разметчик передаёт адреса, а не пересказ. Проект однажды уже держал
review-brief.md и убрал его: второй дом расходится с первым и выглядит
актуальным. Пересказ в задании — тот же посредник, живущий один прогон.
Исключение одно: отсутствие дома, этого проход сам дёшево не выяснит.

quick и standard совпали составом и разошлись глубиной — иначе требование
«нижние ступени закрывают все темы, просто не так глубоко» не выполняется.
Глубин три, и они про способ доказательства, а не про старательность: сверка
(открыть дом, открыть дифф, сравнить), разбор (построить сценарий рассуждением),
доказательство (прогнать, померить, построить путь). Третья есть только в wide.
Цена принята: это единственное место, где профиль не выводится из списка
проходов, поэтому глубина объявляется в отчёте наравне со ступенью.

review-code переписан, и это оказалось крупнее исходной находки: код как код не
читал никто. specs сверял с требованиями, basics — с отказами окружения,
architecture — с устройством, а code был проходом только по прозаическим
конвенциям и прямо объявлял, что рантайм и логика не его. «Здесь ошибка в логике»
не говорил вообще никто. Теперь у прохода две половины: девять классов
технического дефекта (необработанная ветка отказа, пустое и нулевое, граница
диапазона, перепутанный операнд, неосвобождённый ресурс, изменение под итерацией,
неверно применённый интерфейс библиотеки, недостижимая ветка, «сделано соседнее»)
и прежняя сверка с конвенциями. Модель поднята до opus по признаку темы 35: цена
пропущенной находки — дефект в проде.

Канон повышен до версии 5: форма дома на выбор, открытый список тем, AGENTS.md
законно лежит рядом с CLAUDE.md, «Вопросы к проходам» → «Вопросы по темам» (имя
прохода переезд не переживает, тема переживает), «Недоступно проверке» — тоже по
темам. docs.py переписан под темы: ловит двойной дом, принимает обе формы,
перечисляет свои темы проекта вместо «файл вне канона».

Побочно закрыт давний пункт TODO про каталожную форму architecture.md — решать
больше нечего.

Прогон от всего этого стал дороже, а не дешевле, впервые за сессию: плюс scope в
голове каждого прогона, плюс code на opus, плюс basics теперь и в quick. Куплены
разведённость выбора ступени, видимость непокрытых тем и технический разбор кода,
которого не было вовсе.

Тема 36 в DECISIONS.md, следствия 137-140.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-07 08:35:11 +03:00
co-authored by Claude Opus 5
parent c93a9d1269
commit a81dd1a5a7
17 changed files with 1356 additions and 615 deletions
+155 -57
View File
@@ -25,54 +25,58 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
CANON_VERSION = 4
CANON_VERSION = 5
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# --- Раскладка канона -------------------------------------------------------
# Обязательные файлы: путь → на какой вопрос отвечает (для внятного отказа).
# Тема канона: имя → на какой вопрос отвечает (для внятного отказа).
#
# **Тема живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с README.md
# внутри.** Форму выбирает проект: тема разрослась — стала каталогом, и это не
# смена канона и не повод править скрипт. Обе формы сразу — ошибка: это два дома
# для одного факта, ровно то, от чего канон и защищает.
THEMES = {
"passport": "зачем и для кого, чем НЕ является",
"architecture": "как сложено — обзор, окружение, эксплуатация",
"security": "периметр, недоверенный вход, что вне модели",
"conventions": "как мы пишем код; индекс, промоут, что механизировано",
"research": "что показала реальность: наблюдения и числа с провенансом",
"adr": "почему решено так; индекс, статусы, правило замены",
"review": "настройка конвейера + журнал дефектов",
}
# Тема, обязательная только при условии: имя → (ключ .pm.json, пояснение).
CONDITIONAL_THEMES = {
"database": ("migrations", "схема хранилища и настройки"),
}
# Обязательные файлы вне тем.
REQUIRED = {
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
"docs/.pm.json": "версия канона и пути, нужные проверкам",
"docs/passport.md": "зачем и для кого, чем НЕ является",
"docs/architecture.md": "как сложено — обзор, окружение, эксплуатация",
"docs/security.md": "периметр, недоверенный вход, что вне модели",
"docs/review.md": "настройка конвейера + журнал дефектов",
"docs/conventions/README.md": "индекс конвенций, правило промоута, что механизировано",
"docs/research/README.md": "как снималось, индекс наблюдений",
"docs/adr/README.md": "индекс записей, статусы, правило замены",
"docs/adr/template.md": "шаблон записи ADR",
}
# Обязателен только при условии: путь → (ключ .pm.json, пояснение).
CONDITIONAL = {
"docs/database.md": ("migrations", "схема хранилища и настройки"),
# Файлы, которые тема-каталог обязана держать сверх README.md.
THEME_EXTRA = {
"adr": {"template.md": "шаблон записи ADR"},
}
# Что вообще разрешено лежать в docs/ верхним уровнем.
ALLOWED_FILES = {
".pm.json",
"passport.md",
"architecture.md",
"database.md",
"security.md",
"review.md",
}
ALLOWED_DIRS = {"conventions", "research", "adr", "tasks"}
# Служебное в docs/ и каталог, который ведёт tasks.py.
NOT_THEMES = {".pm.json", "tasks"}
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое.
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое. Имена,
# совпадающие с темой, отсюда убраны намеренно: `docs/conventions.md` и
# `docs/review/` теперь законные формы своих тем.
RETIRED = {
"review-brief.md": "документы канона и есть бриф; остаток — в review.md",
"review-journal.md": "docs/review.md",
"review-brief.md": "документы канона и есть бриф; остаток — в review",
"review-journal.md": "тема review",
"plan.md": "→ docs/tasks/ROADMAP.md",
"conventions.md": "docs/conventions/",
"local-research.md": "→ docs/research/",
"research.md": "→ docs/research/",
"specs": "поведение → openspec/specs/, обзор → docs/architecture.md",
"local-research.md": "тема research",
"specs": "поведение → openspec/specs/, обзор → тема architecture",
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
"backlog": "→ docs/tasks/",
"review": "→ docs/review.md",
}
# --- Слаги в именах файлов --------------------------------------------------
@@ -118,11 +122,13 @@ def check_slugs(root: Path, rep: Report) -> None:
if not docs.is_dir():
return
# Имена, выбранные каноном, а не проектом: их форма задана здесь же.
fixed = {"README.md", "template.md"} | ALLOWED_FILES
for sub in ("conventions", "research", "adr"):
folder = docs / sub
if not folder.is_dir():
fixed = {"README.md", "template.md"} | {f"{name}.md" for name in THEMES}
# Все темы-каталоги, включая свои темы проекта: правило имён общее, а
# перечислять их поимённо значило бы закрыть открытый список.
for folder in sorted(docs.iterdir()):
if not folder.is_dir() or folder.name in NOT_THEMES:
continue
sub = folder.name
for path in sorted(folder.rglob("*.md")):
name = path.name
rel = path.relative_to(root)
@@ -261,32 +267,101 @@ def check_version(root: Path, cfg: dict, rep: Report) -> None:
)
def theme_home(root: Path, name: str) -> tuple[Path | None, str | None]:
"""Дом темы: файл `docs/<имя>.md` или каталог `docs/<имя>/`.
Возвращает путь и жалобу. Обе формы сразу — это два дома для одного факта, и
расходятся они молча: правят одну, читают другую.
"""
docs = root / "docs"
as_file = docs / f"{name}.md"
as_dir = docs / name
if as_file.is_file() and as_dir.is_dir():
return as_file, (
f"тема {name} живёт сразу двумя домами — docs/{name}.md и docs/{name}/:"
f" оставить один, иначе правят один, а читают другой"
)
if as_file.is_file():
return as_file, None
if as_dir.is_dir():
if not (as_dir / "README.md").is_file():
return as_dir, (
f"docs/{name}/ без README.md — у темы-каталога вход обязателен:"
f" по нему её читают агенты"
)
return as_dir, None
return None, None
def check_required(root: Path, cfg: dict, rep: Report) -> None:
for rel, what in REQUIRED.items():
if not (root / rel).exists():
rep.error(f"нет {rel}{what}")
for rel, (key, what) in CONDITIONAL.items():
if key in cfg and not (root / rel).exists():
rep.error(f"нет {rel}{what} (обязателен: в .pm.json объявлен {key})")
elif key not in cfg and not (root / rel).exists():
rep.skip(f"{rel} — в .pm.json нет ключа {key}, проверка неприменима")
for name, what in THEMES.items():
home, complaint = theme_home(root, name)
if home is None:
rep.error(f"нет темы {name} (docs/{name}.md или docs/{name}/) — {what}")
continue
if complaint:
rep.error(complaint)
if home.is_dir():
for extra, why in THEME_EXTRA.get(name, {}).items():
if not (home / extra).is_file():
rep.error(f"нет docs/{name}/{extra}{why}")
for name, (key, what) in CONDITIONAL_THEMES.items():
home, complaint = theme_home(root, name)
if complaint:
rep.error(complaint)
if key in cfg and home is None:
rep.error(
f"нет темы {name} (docs/{name}.md или docs/{name}/) — {what}"
f" (обязательна: в .pm.json объявлен {key})"
)
elif key not in cfg and home is None:
rep.skip(f"тема {name} — в .pm.json нет ключа {key}, проверка неприменима")
def check_stray(root: Path, rep: Report) -> None:
"""Лишнего в docs/ больше нет — есть темы проекта.
Список тем **открытый**: каждый документ в docs/ и есть заявка на тему
ревью, и запретить проекту завести свою нельзя. Проверяются только слоты,
у которых дом в другом месте, — иначе переехавшее содержимое вернулось бы
темой и выглядело законным.
"""
docs = root / "docs"
if not docs.is_dir():
rep.error("нет каталога docs/")
return
known = set(THEMES) | set(CONDITIONAL_THEMES)
own: list[str] = []
for entry in sorted(docs.iterdir()):
name = entry.name
if name in RETIRED:
rep.error(f"docs/{name} — слота нет в каноне: {RETIRED[name]}")
continue
if entry.is_dir():
if name not in ALLOWED_DIRS:
rep.error(f"docs/{name}/ — каталог вне канона")
elif name not in ALLOWED_FILES:
rep.error(f"docs/{name} — файл вне канона")
if name in NOT_THEMES:
continue
theme = name[:-3] if entry.is_file() and name.endswith(".md") else name
if theme in known:
continue
if entry.is_file() and not name.endswith(".md"):
rep.error(f"docs/{name} — не markdown: тема ревью читается как текст")
continue
if entry.is_dir() and not (entry / "README.md").is_file():
rep.error(
f"docs/{name}/ без README.md — у темы-каталога вход обязателен:"
f" по нему её читают агенты"
)
continue
own.append(theme)
if own:
rep.note(
f"свои темы проекта: {', '.join(own)} — их разбирает review-basics,"
f" именного прохода у них нет"
)
def canon_docs(root: Path) -> list[Path]:
@@ -302,9 +377,13 @@ def canon_docs(root: Path) -> list[Path]:
if head in skip or head in RETIRED:
continue
out.append(path)
claude = root / "CLAUDE.md"
if claude.exists():
out.append(claude)
# AGENTS.md лежит рядом с CLAUDE.md и читается теми же агентами: он почти
# стандарт, и проект вправе держать оба. Обязателен по-прежнему только
# первый.
for name in ("CLAUDE.md", "AGENTS.md"):
path = root / name
if path.exists():
out.append(path)
return out
@@ -339,19 +418,35 @@ def check_placeholders_and_debt(root: Path, rep: Report) -> None:
rep.debt(f"{rel}: {what}")
def theme_text(root: Path, name: str) -> str | None:
"""Текст темы целиком: файл или все markdown каталога, склеенные.
Проверке всё равно, одним файлом написана тема или десятью: она ищет
упоминание, а упоминание живёт в любом из них.
"""
home, _ = theme_home(root, name)
if home is None:
return None
if home.is_file():
return home.read_text(encoding="utf-8", errors="replace")
return "\n".join(
path.read_text(encoding="utf-8", errors="replace")
for path in sorted(home.rglob("*.md"))
)
def check_capabilities(root: Path, rep: Report) -> None:
specs = root / "openspec" / "specs"
arch = root / "docs" / "architecture.md"
text = theme_text(root, "architecture")
if not specs.is_dir():
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
return
if not arch.exists():
if text is None:
rep.skip(
"docs/architecture.md нет — capability не сверены с обзором "
"(об отсутствии файла сказано отдельной строкой)"
"темы architecture нет — capability не сверены с обзором "
"(об отсутствии сказано отдельной строкой)"
)
return
text = arch.read_text(encoding="utf-8", errors="replace")
for d in sorted(specs.iterdir()):
if not d.is_dir():
continue
@@ -365,14 +460,14 @@ def check_capabilities(root: Path, rep: Report) -> None:
continue
if loose:
rep.note(
f"capability {name}: в docs/architecture.md есть слово «{name}», но "
f"capability {name}: в теме architecture есть слово «{name}», но "
f"нет ни ссылки на openspec/specs/{name}, ни имени в обратных "
f"кавычках — проверь, это про capability или про пакет"
)
else:
rep.error(
f"capability {name} есть в openspec/specs/, но не упомянута в "
f"docs/architecture.md — обзор отстал от нормативных спек"
f"теме architecture — обзор отстал от нормативных спек"
)
@@ -416,9 +511,12 @@ def check_migrations(root: Path, cfg: dict, base: str | None, rep: Report) -> No
touched = [f for f in changed if f.startswith(migrations.rstrip("/") + "/")]
if not touched:
return
if "docs/database.md" not in changed:
# Тема database бывает файлом и каталогом — правкой считается любой её файл.
if not any(
f == "docs/database.md" or f.startswith("docs/database/") for f in changed
):
rep.error(
f"миграции изменены ({len(touched)} файлов), а docs/database.md — нет: "
f"миграции изменены ({len(touched)} файлов), а тема database — нет: "
f"схема в документации отстала"
)