Files
dev-skills/scripts/frontmatter.py
T
av b411d4edb8 оси: перечень получил дом, две бездомные оси переехали в shared
Слияние ничего из идей не тронуло, но сделало дешёвым дом для правила, натянутого
между скиллами. Заведён shared/axes.md — дом перечня, а не значений: девять осей,
их адреса и чего каждая не решает. Механика остаётся у владельца.

Целиком сюда переехали две оси, у которых владельца не было. Коды выхода
объявлялись общим словарём в одиннадцати местах, и каждое объявление называло
свой набор соседей; машина их не сверяла, потому что copies.py смотрит markdown,
а перечни лежали в docstring'ах. Теперь дом один, скрипты держат указатель, а три
SKILL.md — помеченную копию, потому что на кодах они ветвятся. Режим прогона
(с меткой, без метки) был размазан по четырём файлам и осью назван не был, хотя
в уставе review-basics задаёт саму возможность запуска.

Разведены два значения слова «стадия»: ступени 1-5 внутри прогона кода, стадии
дизайна и кода снаружи.

Карта нашла ошибку в себе: клетка «категория документа × метка» пустой не была —
review-basics приёмник проектных тем при любой метке. Пустой оказалась соседняя:
на прогоне без метки план фиксирован, и своих тем проекта в нём нет вовсе.
Обе оставшиеся пустоты названы вслух, а не заполнены наугад.
2026-08-13 12:16:38 +03:00

223 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Проверка фронтматтеров скиллов и charter'ов этого репозитория и описаний плагинов.
Фронтматтер — единственная часть скилла, которую читает не человек, а загрузчик:
по `name` он разрешает вызов, по `description` решает, звать ли скилл вообще.
Ошибка здесь не выглядит ошибкой. Текст остаётся читаемым, `git diff` показывает
разумную строку, а скилл либо не находится по имени, либо загружается с
обрезанным описанием и потому не срабатывает на своих же триггерах.
Ловится четыре класса.
**Двоеточие с пробелом в описании без кавычек.** В YAML `: ` внутри простого
скаляра начинает вложенное отображение — строка «конвейер ревью: гейт, сверка…»
это не текст с двоеточием, а синтаксическая ошибка. Так были написаны три
описания из четырнадцати; заметить это чтением нельзя, потому что читается оно
правильно.
**Имя, разошедшееся с каталогом.** Скилл зовётся по имени каталога, а `name`
внутри — то, чем он представляется. Разъехались — вызов не разрешается, и
сообщение об этом говорит «нет такого скилла», а не «имя не то».
**Цвет, не отвечающий модели.** Цвет charter'а кодирует **модель**, на которой
идёт проход, а не его роль: раскладка — в
`av-dev/skills/code-review/SKILL.md`, раздел «Модель по проходу».
Правило существует ровно затем, чтобы стоимость прогона читалась взглядом по
списку агентов, и держаться вниманием оно не может: цвет ставится один раз при
заведении charter'а, а модель потом меняется калибровкой.
**Описание плагина, разошедшееся между манифестами.** У описания два дома:
`<плагин>/.claude-plugin/plugin.json` его показывает установленному плагину,
корневой `.claude-plugin/marketplace.json` — тому, кто выбирает, ставить ли.
Правят обычно один, и разойтись они успели уже трижды из четырёх. `copies.py`
этот класс не берёт: он смотрит markdown, а манифест — json.
Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф
против окружения» — av-dev/shared/axes.md. Значения — в константах ниже.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Дом раскладки — «Модель по проходу» в av-dev/skills/code-review/SKILL.md; здесь её
# механизация. Порядок цветов — порядок стоимости прогона.
PALETTE = {"sonnet": "green", "opus": "yellow"}
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 manifests(root: Path) -> list[tuple[str, list[str]]]:
"""Описание каждого плагина: `plugin.json` против `marketplace.json`."""
market = root / ".claude-plugin" / "marketplace.json"
where = market.relative_to(root).as_posix()
try:
listed = {
str(entry.get("name", "")): str(entry.get("description", ""))
for entry in json.loads(market.read_text(encoding="utf-8"))["plugins"]
}
except (OSError, ValueError, KeyError, TypeError) as e:
return [(where, [f"манифест маркетплейса не разбирается: {e}"])]
found: list[tuple[str, list[str]]] = []
for plugin in sorted(root.glob("av-*/")):
card = plugin / ".claude-plugin" / "plugin.json"
rel = card.relative_to(root).as_posix()
try:
own = json.loads(card.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
found.append((rel, [f"манифест плагина не разбирается: {e}"]))
continue
name = str(own.get("name", plugin.name))
if name not in listed:
found.append((rel, [
f"плагина `{name}` нет в {where} — маркетплейс его не отдаёт"
]))
elif str(own.get("description", "")) != listed[name]:
found.append((rel, [
f"`description` разошлось с записью `{name}` в {where}:"
f" у описания один текст на два манифеста, и правят обычно один"
]))
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)
cards = manifests(root)
plugins = len(list(root.glob("av-*/")))
skills = sum(1 for _, _, required in sheets if required is SKILL_KEYS)
print(f"фронтматтеров {len(sheets)}: скиллов {skills},"
f" charter'ов {len(sheets) - skills}")
print(f"манифестов плагинов {plugins}: описание сверено с marketplace.json")
broken = [sheet for sheet, _, _ in sheets if sheet.problems]
if broken or cards:
print()
for sheet in broken:
for problem in sheet.problems:
print(f"ОШИБКА {sheet.where}\n {problem}")
for rel, problems in cards:
for problem in problems:
print(f"ОШИБКА {rel}\n {problem}")
print(f"\nИтог: с ошибками {len(broken) + len(cards)}"
f" из {len(sheets) + plugins}.")
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)