av-dev-pipeline: бриф удалён, проходы читают документы канона напрямую
- удалены скилл project-brief и контракт брифа; вместо них references/ project-facts.md — карта «что нужно проходу → где лежит» и таблица поразрядной деградации по документам - девять charter'ов, review-pipeline, task-pipeline и task-batch переписаны на пути канона; OpenSpec стал объявленной предпосылкой без ветки деградации - шаг синка документации переписан в построчный доклад, закрытие задачи — вызовом скилла av-dev-pm:tasks вместо строки-слота из CLAUDE.md - по находкам ревью: docs.py звал tasks.py из чужого каталога и выдавал его отказ окружения за дрейф; сверка миграций не видела рабочее дерево; плейсхолдер краснел вместо замечания; сверка capability проходила по совпадению с именем пакета; tasks.py не читал docs/.pm.json; скилл docs пересказывал канон в пяти местах
This commit is contained in:
@@ -17,7 +17,10 @@ description: Привести проект к канону документов
|
||||
пересказывается: два описания одной раскладки разъедутся, и работать будет то,
|
||||
которое прочитали последним. Прочитай его **до** первой правки.
|
||||
|
||||
Журнал версий — [references/changelog.md](references/changelog.md).
|
||||
- [references/skeletons.md](references/skeletons.md) — **что именно класть** в
|
||||
каждый незаполненный слот. Своей формой заглушку не выдумывай: `docs.py`
|
||||
узнаёт только плейсхолдер `<!-- заполнить: … -->` из шаблонов.
|
||||
- [references/changelog.md](references/changelog.md) — журнал версий канона.
|
||||
|
||||
## Три правила, из которых всё следует
|
||||
|
||||
@@ -53,9 +56,13 @@ python3 $ds version --dir <корень> # версия кано
|
||||
нельзя**. `check`, отчитавшийся «канон соблюдён» на проекте, где из шести файлов
|
||||
три лишние, хуже отсутствующего.
|
||||
|
||||
Машина проверяет пути, лишние файлы, битые ссылки, версию, нетронутые
|
||||
плейсхолдеры, маркеры долга и две сверки с кодом. **Ты** судишь о том, чего она
|
||||
не умеет:
|
||||
Машина **дрейфом** считает: отсутствующий путь канона, файл вне канона, битую
|
||||
ссылку, отставшую версию, capability без упоминания в обзоре, миграцию без правки
|
||||
`database.md`. **Замечанием** — незаполненный плейсхолдер и слабое упоминание
|
||||
capability: незаполненный канон это переходное состояние, а не отказ. Маркеры
|
||||
долга просто считает числом.
|
||||
|
||||
**Ты** судишь о том, чего она не умеет:
|
||||
|
||||
- **смысловой дубль** — `docs/specs/recognition.md` описывает то же, что
|
||||
capability `recognition`. Файлы разные, содержание одно;
|
||||
@@ -113,8 +120,8 @@ capability), `openspec/config.yaml`.
|
||||
Порядок важен — он минимизирует окно, в котором ссылки битые:
|
||||
|
||||
1. `docs/.pm.json` с `{"canon": <текущая версия>}` и путём миграций, если БД есть;
|
||||
2. каталоги канона и скелет: незаполненное — **одной честной информативной
|
||||
строкой**, а не «TBD» (см. canon.md, «Пустое называется пустым»);
|
||||
2. каталоги канона и скелет **по [references/skeletons.md](references/skeletons.md)**:
|
||||
незаполненное — одной честной информативной строкой, а не «TBD»;
|
||||
3. переносы содержимого;
|
||||
4. каталог задач — **вызови скилл `av-dev-pm:tasks`**, сценарий адаптации: он
|
||||
владеет форматом задач, включая переименование транслитных слагов в
|
||||
@@ -122,8 +129,15 @@ capability), `openspec/config.yaml`.
|
||||
5. починка ссылок на перенесённое во всём репозитории — `docs/`, `openspec/`,
|
||||
`CLAUDE.md`, `README.md`;
|
||||
6. удаление оригиналов — **только тех, чьё содержимое найдено в новом доме**;
|
||||
7. шаг `docs.py check` в гейт проекта;
|
||||
8. `docs.py check` — до зелёного в механизируемой части.
|
||||
7. **шаг `docs.py check` в гейт проекта.** Путь к скрипту — переменной с
|
||||
умолчанием на канонический путь маркетплейса, чтобы переустановка плагина не
|
||||
меняла `Taskfile`; шаг обязан **краснеть внятно**, если скрипт не найден, а не
|
||||
пропускаться. Передай ему базу диффа (`--base`) той же переменной, что и
|
||||
остальным шагам гейта: без неё сверка миграций со схемой не гоняется вовсе.
|
||||
Пример строки покажи человеку — гейт принадлежит проекту, и правит его он;
|
||||
8. `docs.py check` — до **отсутствия дрейфа**. Замечания (незаполненные
|
||||
плейсхолдеры, слабое упоминание capability) остаются: незаполненный канон это
|
||||
объявленное переходное состояние из шага 5, а не отказ.
|
||||
|
||||
### 5. Объяви переходное состояние
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
Плейсхолдер ставится только там, где ответ **обязан** быть и его не спросили.
|
||||
Всё, чего в проекте пока просто нет, описывается словами, а не плейсхолдером.
|
||||
|
||||
**Шаблоны — единственное место, где правило канона копируется намеренно.**
|
||||
`adr/README.md` и `review.md` уезжают в репозиторий проекта и обязаны там что-то
|
||||
говорить; определение при этом остаётся в [canon.md](canon.md). Отсюда
|
||||
обязанность: **правка такого правила в каноне тянет запись в
|
||||
[changelog.md](changelog.md)** с указанием, какой файл проекта поднимает
|
||||
`upgrade`. Без этого копия в проекте останется на старой версии молча.
|
||||
|
||||
## `docs/passport.md`
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -76,19 +76,25 @@ RETIRED = {
|
||||
|
||||
DEBT_MARKER = re.compile(r"<!--\s*канон:\s*(.+?)\s*-->")
|
||||
PLACEHOLDER = re.compile(r"<!--\s*заполнить:\s*(.+?)\s*-->")
|
||||
MD_LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
||||
MD_LINK = re.compile(r"\[[^\]]*\]\(\s*<?([^)>\s]+)>?(?:\s+[\"'(][^)]*)?\)")
|
||||
FENCE = re.compile(r"^\s*(```|~~~)")
|
||||
|
||||
|
||||
INLINE_CODE = re.compile(r"`[^`\n]*`")
|
||||
|
||||
|
||||
def strip_code(text: str) -> str:
|
||||
"""Выкинуть блоки кода: пути в примерах и шаблонах — не ссылки, и краснеть
|
||||
на них значит краснеть на каждом образце документа."""
|
||||
"""Выкинуть блоки кода и вставки в обратных кавычках.
|
||||
|
||||
Путь в примере или в шаблоне — не ссылка, и краснеть на нём значит краснеть
|
||||
на каждом образце документа. Инлайн-код тоже: `[docs/backlog](docs/tasks/…)`
|
||||
в тексте про подписи ссылок — иллюстрация, а не ссылка."""
|
||||
out, inside = [], False
|
||||
for line in text.splitlines():
|
||||
if FENCE.match(line):
|
||||
inside = not inside
|
||||
continue
|
||||
out.append("" if inside else line)
|
||||
out.append("" if inside else INLINE_CODE.sub("", line))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@@ -226,7 +232,9 @@ def check_placeholders_and_debt(root: Path, rep: Report) -> None:
|
||||
text = strip_code(path.read_text(encoding="utf-8", errors="replace"))
|
||||
rel = path.relative_to(root)
|
||||
for what in PLACEHOLDER.findall(text):
|
||||
rep.error(f"{rel}: плейсхолдер шаблона не заполнен — {what}")
|
||||
# Замечание, а не дрейф: незаполненный канон — объявленное переходное
|
||||
# состояние, и краснеть на нём значит требовать выдумать содержание.
|
||||
rep.note(f"{rel}: плейсхолдер шаблона не заполнен — {what}")
|
||||
for what in DEBT_MARKER.findall(text):
|
||||
rep.debt(f"{rel}: {what}")
|
||||
|
||||
@@ -238,28 +246,60 @@ def check_capabilities(root: Path, rep: Report) -> None:
|
||||
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
|
||||
return
|
||||
if not arch.exists():
|
||||
rep.skip(
|
||||
"docs/architecture.md нет — capability не сверены с обзором "
|
||||
"(об отсутствии файла сказано отдельной строкой)"
|
||||
)
|
||||
return
|
||||
text = arch.read_text(encoding="utf-8", errors="replace")
|
||||
missing = [d.name for d in sorted(specs.iterdir()) if d.is_dir() and d.name not in text]
|
||||
for name in missing:
|
||||
rep.error(
|
||||
f"capability {name} есть в openspec/specs/, но не упомянута в "
|
||||
f"docs/architecture.md — обзор отстал от нормативных спек"
|
||||
)
|
||||
for d in sorted(specs.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
name = d.name
|
||||
# Засчитываем только явное упоминание: ссылку на спеку или имя в обратных
|
||||
# кавычках. Голая подстрока совпадает с именем пакета или CLI-команды и
|
||||
# даёт ложное «упомянуто» — то есть проверку, проходящую не по той причине.
|
||||
explicit = f"openspec/specs/{name}" in text or f"`{name}`" in text
|
||||
loose = re.search(rf"\b{re.escape(name)}\b", text) is not None
|
||||
if explicit:
|
||||
continue
|
||||
if loose:
|
||||
rep.note(
|
||||
f"capability {name}: в docs/architecture.md есть слово «{name}», но "
|
||||
f"нет ни ссылки на openspec/specs/{name}, ни имени в обратных "
|
||||
f"кавычках — проверь, это про capability или про пакет"
|
||||
)
|
||||
else:
|
||||
rep.error(
|
||||
f"capability {name} есть в openspec/specs/, но не упомянута в "
|
||||
f"docs/architecture.md — обзор отстал от нормативных спек"
|
||||
)
|
||||
|
||||
|
||||
def changed_files(root: Path, base: str, rep: Report) -> list[str] | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(root), "diff", "--name-only", f"{base}...HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
rep.skip(f"сверка миграций пропущена: git не отдал дифф ({exc})")
|
||||
return None
|
||||
return [line for line in out.stdout.splitlines() if line]
|
||||
"""Объединение закоммиченного, рабочего дерева и untracked.
|
||||
|
||||
Гейт гоняют ДО коммита, поэтому `base...HEAD` не видит ровно ту правку, ради
|
||||
которой проверка и заводилась: миграция уже лежит в дереве, но ещё не в
|
||||
истории. Пропущенная правка выглядела бы как зелёный шаг."""
|
||||
cmds = [
|
||||
["diff", "--name-only", base],
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
]
|
||||
seen: list[str] = []
|
||||
for cmd in cmds:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(root), *cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
rep.skip(f"сверка миграций пропущена: git не отдал дифф ({exc})")
|
||||
return None
|
||||
seen.extend(line for line in out.stdout.splitlines() if line)
|
||||
return sorted(set(seen))
|
||||
|
||||
|
||||
def check_migrations(root: Path, cfg: dict, base: str | None, rep: Report) -> None:
|
||||
@@ -292,17 +332,24 @@ def check_tasks(root: Path, rep: Report) -> None:
|
||||
if not script.exists():
|
||||
rep.skip(f"tasks.py не найден по пути {script} — согласованность задач не проверена")
|
||||
return
|
||||
# cwd=root обязателен: tasks.py отвергает --dir вне текущего каталога, и без
|
||||
# этого его отказ окружения (код 3) схлопнулся бы в наш дрейф (код 1).
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script), "check", "--dir", str(tasks)],
|
||||
[sys.executable, str(script), "check", "--dir", "docs/tasks"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(root),
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
if proc.returncode == 1:
|
||||
rep.error("tasks.py check нашёл дрейф в docs/tasks/ — разбирать его командой tasks.py")
|
||||
else:
|
||||
rep.error(f"tasks.py check отказал с кодом {proc.returncode}: {proc.stderr.strip()}")
|
||||
# Чужой код выхода не выдаём за свой: 3 это окружение, а не дрейф.
|
||||
rep.skip(
|
||||
f"tasks.py check не отработал (код {proc.returncode}): "
|
||||
f"{(proc.stderr or proc.stdout).strip().splitlines()[0] if (proc.stderr or proc.stdout).strip() else 'без сообщения'}"
|
||||
)
|
||||
|
||||
|
||||
# --- Отчёт ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user