ревью: ступень wide, цвета по модели, проверка фронтматтеров
Прыжок standard → deep стоил самого дорогого прохода конвейера, а платить приходилось за одну архитектурную находку: изменений, которые трогают публичный контракт, но не вводят нового правила слияния, — большинство. Ступень wide это standard плюс architecture (вход шире диффа, отсюда имя), семь проходов против восьми. Заодно вычистилась давняя неровность: триггер reimpl стоял внутри deep, и профиль означал то семь проходов, то восемь — реестр состава, который «сверяется взглядом до коммита», проверять было нечем. Теперь условие «новое правило идентичности, слияния или разбора» выбирает профиль, reimpl в deep безусловен и есть единственное отличие от wide. Барьер стоимости остался только в deep: в wide за ним стоял бы один дешёвый проход с потолком в 3 находки, а барьер сериализует то, что могло идти разом. Цвет charter'а теперь кодирует модель, а не роль: sonnet → green, opus → yellow, fable → red. Роль видна из имени, стоимость прогона — ниоткуда, а список агентов читается взглядом. scripts/frontmatter.py ловит три класса ошибок, невидимых при чтении: - двоеточие с пробелом в незакавыченном описании — для YAML это вложенное отображение, а не текст. Так было написано три описания из четырнадцати, и читались они правильно; - name, разошедшееся с именем каталога скилла или файла charter'а; - цвет, не отвечающий модели: он ставится один раз при заведении charter'а, а модель потом двигает калибровка. Обе ветки проверены, коды выхода — общий словарь. Триггеры профиля в canon.md и skeletons.md подтянуты под wide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка фронтматтеров скиллов и charter'ов этого репозитория.
|
||||
|
||||
Фронтматтер — единственная часть скилла, которую читает не человек, а загрузчик:
|
||||
по `name` он разрешает вызов, по `description` решает, звать ли скилл вообще.
|
||||
Ошибка здесь не выглядит ошибкой. Текст остаётся читаемым, `git diff` показывает
|
||||
разумную строку, а скилл либо не находится по имени, либо загружается с
|
||||
обрезанным описанием и потому не срабатывает на своих же триггерах.
|
||||
|
||||
Ловится три класса.
|
||||
|
||||
**Двоеточие с пробелом в описании без кавычек.** В YAML `: ` внутри простого
|
||||
скаляра начинает вложенное отображение — строка «конвейер ревью: гейт, сверка…»
|
||||
это не текст с двоеточием, а синтаксическая ошибка. Так были написаны три
|
||||
описания из четырнадцати; заметить это чтением нельзя, потому что читается оно
|
||||
правильно.
|
||||
|
||||
**Имя, разошедшееся с каталогом.** Скилл зовётся по имени каталога, а `name`
|
||||
внутри — то, чем он представляется. Разъехались — вызов не разрешается, и
|
||||
сообщение об этом говорит «нет такого скилла», а не «имя не то».
|
||||
|
||||
**Цвет, не отвечающий модели.** Цвет charter'а кодирует **модель**, на которой
|
||||
идёт проход, а не его роль: раскладка — в
|
||||
`av-dev-pipeline/skills/review-pipeline/SKILL.md`, раздел «Модель по проходу».
|
||||
Правило существует ровно затем, чтобы стоимость прогона читалась взглядом по
|
||||
списку агентов, и держаться вниманием оно не может: цвет ставится один раз при
|
||||
заведении charter'а, а модель потом меняется калибровкой.
|
||||
|
||||
Коды выхода — тот же словарь, что у tasks.py, docs.py, copies.py и diagrams.py:
|
||||
0 все фронтматтеры в порядке
|
||||
1 расхождение
|
||||
2 ошибка употребления: аргументы
|
||||
3 окружение: не тот каталог
|
||||
4 внутренний сбой
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||
|
||||
# Дом раскладки — «Модель по проходу» в review-pipeline/SKILL.md; здесь её
|
||||
# механизация. Порядок цветов — порядок стоимости прогона.
|
||||
PALETTE = {"sonnet": "green", "opus": "yellow", "fable": "red"}
|
||||
|
||||
SKILL_KEYS = {"name", "description"}
|
||||
AGENT_KEYS = {"name", "description", "tools", "model", "color"}
|
||||
|
||||
|
||||
class Sheet:
|
||||
"""Разобранный фронтматтер одного файла."""
|
||||
|
||||
def __init__(self, path: Path, root: Path) -> None:
|
||||
self.path = path
|
||||
self.where = path.relative_to(root).as_posix()
|
||||
self.fields: dict[str, str] = {}
|
||||
self.problems: list[str] = []
|
||||
# Разбор дошёл до полей. Ложь — фронтматтера нет вовсе, и спрашивать с
|
||||
# него имя, набор полей и цвет бессмысленно: ответ будет один и тот же.
|
||||
self.parsed = False
|
||||
self._parse()
|
||||
|
||||
def _parse(self) -> None:
|
||||
lines = self.path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
self.problems.append("нет фронтматтера: первая строка не `---`")
|
||||
return
|
||||
try:
|
||||
end = lines.index("---", 1)
|
||||
except ValueError:
|
||||
self.problems.append("фронтматтер не закрыт строкой `---`")
|
||||
return
|
||||
self.parsed = True
|
||||
for number, line in enumerate(lines[1:end], start=2):
|
||||
if not line.strip():
|
||||
continue
|
||||
key, sep, value = line.partition(":")
|
||||
if not sep or not key or key != key.strip():
|
||||
self.problems.append(f"строка {number}: не `ключ: значение`")
|
||||
continue
|
||||
value = value.strip()
|
||||
self.fields[key] = value
|
||||
if value[:1] in ('"', "'"):
|
||||
continue
|
||||
if ": " in value:
|
||||
self.problems.append(
|
||||
f"строка {number}: у `{key}` двоеточие с пробелом в значении"
|
||||
f" без кавычек — для YAML это вложенное отображение,"
|
||||
f" а не текст. Обернуть значение в двойные кавычки"
|
||||
)
|
||||
|
||||
def check(self, expected_name: str, required: set[str]) -> None:
|
||||
missing = sorted(required - self.fields.keys())
|
||||
if missing:
|
||||
self.problems.append(f"нет обязательных полей: {', '.join(missing)}")
|
||||
name = self.fields.get("name", "").strip("\"'")
|
||||
if name and name != expected_name:
|
||||
self.problems.append(
|
||||
f"`name: {name}` разошлось с ожидаемым `{expected_name}`"
|
||||
f" — вызов разрешается по второму"
|
||||
)
|
||||
model = self.fields.get("model", "").strip("\"'")
|
||||
color = self.fields.get("color", "").strip("\"'")
|
||||
if model and color:
|
||||
if model not in PALETTE:
|
||||
self.problems.append(
|
||||
f"модель `{model}` не в раскладке цветов"
|
||||
f" ({', '.join(sorted(PALETTE))}) — назначить ей цвет"
|
||||
f" в «Модель по проходу» и здесь"
|
||||
)
|
||||
elif color != PALETTE[model]:
|
||||
self.problems.append(
|
||||
f"цвет `{color}` не отвечает модели `{model}`:"
|
||||
f" по раскладке — `{PALETTE[model]}`"
|
||||
)
|
||||
|
||||
|
||||
def collect(root: Path) -> list[tuple[Sheet, str, set[str]]]:
|
||||
"""Все фронтматтеры репозитория: лист, ожидаемое имя, обязательные поля."""
|
||||
found: list[tuple[Sheet, str, set[str]]] = []
|
||||
for plugin in sorted(root.glob("av-*/")):
|
||||
for skill in sorted(plugin.glob("skills/*/SKILL.md")):
|
||||
found.append((Sheet(skill, root), skill.parent.name, SKILL_KEYS))
|
||||
for agent in sorted(plugin.glob("agents/*.md")):
|
||||
found.append((Sheet(agent, root), agent.stem, AGENT_KEYS))
|
||||
return found
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Проверка фронтматтеров.")
|
||||
ap.add_argument("--dir", default=".", help="корень репозитория")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = Path(args.dir).resolve()
|
||||
if not (root / ".claude-plugin").is_dir():
|
||||
print(f"окружение: {root} не похож на корень репозитория"
|
||||
f" (нет .claude-plugin)", file=sys.stderr)
|
||||
return ENV
|
||||
|
||||
sheets = collect(root)
|
||||
if not sheets:
|
||||
print("окружение: не нашлось ни одного SKILL.md или charter'а",
|
||||
file=sys.stderr)
|
||||
return ENV
|
||||
|
||||
for sheet, expected, required in sheets:
|
||||
if sheet.parsed:
|
||||
sheet.check(expected, required)
|
||||
|
||||
skills = sum(1 for _, _, required in sheets if required is SKILL_KEYS)
|
||||
print(f"фронтматтеров {len(sheets)}: скиллов {skills},"
|
||||
f" charter'ов {len(sheets) - skills}")
|
||||
|
||||
broken = [sheet for sheet, _, _ in sheets if sheet.problems]
|
||||
if broken:
|
||||
print()
|
||||
for sheet in broken:
|
||||
for problem in sheet.problems:
|
||||
print(f"ОШИБКА {sheet.where}\n {problem}")
|
||||
print(f"\nИтог: с ошибками {len(broken)} из {len(sheets)}.")
|
||||
return DRIFT
|
||||
|
||||
print("все в порядке")
|
||||
return OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(INTERNAL)
|
||||
except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю
|
||||
print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
|
||||
sys.exit(INTERNAL)
|
||||
Reference in New Issue
Block a user