ревью по темам: документ проекта стал направлением проверки
Замечено при сверке документов канона с составом ступеней: три документа остались без читателя ниже 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:
@@ -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"схема в документации отстала"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user