Files
dev-skills/scripts/addresses.py
T
av de12a4d8a3 слияние: три плагина стали одним av-dev, скиллы получили префиксы
Каталоги, агенты и общие дома переехали в av-dev/; скиллы названы по прежнему
плагину — doc-*, task-*, code-*, с двумя смысловыми именами вместо тавтологии:
doc-sync вместо docs, task-track вместо tasks. Манифесты сведены к двум
плагинам. Пространства имён вызовов и пути внутри дерева переписаны машинно;
проза, которая называет прежние плагины отдельными, идёт следующим шагом.
2026-08-13 10:10:51 +03:00

220 lines
12 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
"""Сверка чужих адресов в прозе плагинов с перечнем их владельца.
Судится **упразднённое, а не незнакомое**, и это следует из канона, а не из
осторожности: список тем открытый — всё, что проект кладёт в `docs/` сверх
закрытых категорий, законная тема. Значит незнакомое имя опровергнуть нечем, а
переименование и упразднение ловятся точно: канон, убирая слот, кладёт его в
карту переездов, и именно она здесь и есть перечень запрещённого. Рядом
единственная догадка — имя, **почти** совпавшее с каноническим: это опечатка с
куда большей вероятностью, чем новая тема.
Адрес документа принадлежит одному плагину, а называют его все: `docs/*` стоит
примерно в сорока местах `av-dev-code`, `tasks/ROADMAP.md` — в четырёх местах
`av-dev-docs`. Переименование в каноне до этих мест не доходит.
**Почему тут нужна машина, а не аккуратность.** Прогон ревью умеет честно
деградировать: дома темы нет — в границах покрытия появляется строка «документа в
проекте нет» с названной ценой. Протухший адрес попадает ровно в эту машинерию —
файл не открылся, строка напечаталась, и отчёт выглядит добросовестным. То есть
единственный признак ошибки, на который можно было бы рассчитывать — громкая
поломка, — деградацией и убран. Здесь он возвращается гейтом.
Перечень адресов берётся из **константы владельца** — той самой, по которой он и
так проверяет раскладку. Второй перечень прозой был бы вторым домом ровно того
сорта, против которого всё это написано.
addresses.py [корень]
Коды выхода — общий словарь скриптов av-dev:
0 сошлось
1 дрейф: неизвестный или упразднённый адрес
2 ошибка употребления
3 окружение: не тот каталог, перечень владельца недоступен
4 внутренний сбой
"""
from __future__ import annotations
import difflib
import importlib.util
import re
import sys
from pathlib import Path
from types import ModuleType
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", "tmp"}
# Владельцы: префикс адреса → скрипт, который этим каталогом и владеет.
OWNERS = {
"docs": "av-dev/skills/doc-canon/scripts/docs.py",
"tasks": "av-dev/skills/task-track/scripts/tasks.py",
}
# Журналы: описывают прошлые состояния и задним числом не переписываются.
# Адрес, верный на момент записи, здесь останется навсегда, и это не дрейф.
JOURNALS = {
"av-dev/skills/doc-canon/references/changelog.md": "журнал версий канона",
"av-dev/skills/task-track/references/changelog.md": "журнал версий формата задач",
"DECISIONS.md": "журнал решений",
"HISTORY.md": "журнал работ",
"NOTES.md": "рабочие заметки",
}
# Файлы, где упразднённый адрес назван по делу: карта переездов и сценарии
# перевода чужой раскладки. Неизвестные адреса в них проверяются как везде.
RETIRED_OK = {
"av-dev/skills/doc-canon/references/canon.md": "карта упразднённых слотов",
"av-dev/skills/doc-canon/SKILL.md": "adopt: что где искать в чужой раскладке",
"av-dev/skills/task-track/references/adopt.md": "перевод чужого каталога задач",
}
# Адрес в прозе: начало токена, префикс владельца, остаток пути. Отрицательный
# просмотр назад отсекает хвосты чужих путей — `openspec/changes/…/tasks.md`
# адресом каталога задач не является.
ADDRESS = re.compile(r"(?<![\w/.-])(docs|tasks)/([\w./*-]*)")
# Порог близости к каноническому имени, за которым имя читается как опечатка, а
# не как своя тема проекта. Замер по именам, встреченным в репозитории: самое
# близкое законное — `recognition` против `conventions`, 0.64; опечатки
# (`architeture`, `securty`, `revew`, `datbase`) дают 0.910.96. Порог стоит в
# пустоте между ними, и запас с обеих сторон больше 0.15.
NEAR = 0.8
def load(root: Path, rel: str) -> ModuleType:
"""Скрипт владельца как модуль: константы берутся у него, а не рядом."""
path = root / rel
name = f"владелец_{path.stem}"
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise OSError(f"не читается {rel}")
mod = importlib.util.module_from_spec(spec)
# Модуль обязан лежать в sys.modules **до** исполнения: `@dataclass` внутри
# ищет там своё пространство имён и без этого падает.
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
def stem(name: str) -> str:
"""Имя документа без формы: файл, каталог и `.*` — один и тот же адрес.
Форму дома канон оставляет проекту: `docs/security.md` и `docs/security/`
называют одно. Скрытые имена (`.docs.json`) остаются как есть — точка в них
не расширение.
"""
name = name.rstrip(".")
if name.startswith("."):
return name.lower()
return name.split(".", 1)[0].lower()
def vocabularies(root: Path) -> tuple[dict[str, set[str]], dict[str, str]]:
"""Что владельцы считают своим: префикс → имена, плюс карта упразднённого."""
docs = load(root, OWNERS["docs"])
tasks = load(root, OWNERS["tasks"])
docs_names = {stem(n) for n in docs.DOCS}
docs_names |= {stem(n) for n in docs.CONDITIONAL_DOCS}
docs_names |= {stem(n) for n in docs.NOT_DOCS}
# `docs/.docs.json` объявлен обязательным файлом вне раскладки.
docs_names |= {stem(Path(p).name) for p in docs.REQUIRED if p.startswith("docs/")}
tasks_names = {stem(tasks.DEFAULTS[k]) for k in tasks.PATH_KEYS}
tasks_names |= {stem(tasks.CONFIG_NAME)}
retired = {stem(n): why for n, why in docs.RETIRED.items()}
return {"docs": docs_names, "tasks": tasks_names}, retired
def walk(root: Path) -> list[Path]:
out = []
for p in sorted(root.rglob("*.md")):
rel = p.relative_to(root)
if SKIP_DIRS & set(rel.parts):
continue
if rel.as_posix() in JOURNALS:
continue
out.append(p)
return out
def main() -> int:
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
if not (root / ".claude-plugin").is_dir():
print(f"ОТКАЗ: {root} не похож на корень маркетплейса: нет .claude-plugin/",
file=sys.stderr)
return ENV
try:
known, retired = vocabularies(root)
except Exception as e: # noqa: BLE001 — перечень владельца обязан быть доступен
print(f"ОТКАЗ: перечень адресов не взять у владельца: {e}", file=sys.stderr)
return ENV
findings: list[str] = []
files = walk(root)
seen = 0
own_themes: set[str] = set()
for path in files:
rel = path.relative_to(root).as_posix()
retired_ok = rel in RETIRED_OK
for num, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for m in ADDRESS.finditer(line):
owner, rest = m.group(1), m.group(2)
first = rest.split("/", 1)[0]
if not first or first.startswith("*"):
continue # сам каталог или шаблон по всем документам
seen += 1
name = stem(first)
if name in known[owner]:
continue
if name in retired:
if not retired_ok:
findings.append(
f"{rel}:{num}: `{m.group(0)}` — слот упразднён,"
f" содержимое {retired[name]}")
continue
near = difflib.get_close_matches(name, sorted(known[owner]),
n=1, cutoff=NEAR)
if near:
findings.append(
f"{rel}:{num}: `{m.group(0)}` — у владельца ({owner})"
f" такого адреса нет, а «{near[0]}» есть: похоже на опечатку")
continue
own_themes.add(f"{owner}/{name}")
print(f"адресов встречено {seen} в {len(files)} файлах;"
f" перечни взяты из {', '.join(sorted(OWNERS.values()))}")
if findings:
print()
for f in findings:
print(f"РАСХОЖДЕНИЕ {f}")
print(f"\nИтог: расхождений {len(findings)}. Правится **упоминание**,"
f" а не перечень: перечень — то, по чему владелец проверяет"
f" раскладку проекта.")
return DRIFT
print("упразднённых адресов нет")
if own_themes:
print(f"Имён вне перечня {len(own_themes)}, и они **не судятся** —"
f" список тем открытый: {', '.join(sorted(own_themes))}.")
print(f"Не проверялось: журналы ({len(JOURNALS)} шт. — они описывают"
f" прошлые состояния), адреса `openspec/*` (раскладка чужого"
f" инструмента, у нас владельца нет), упоминания в комментариях"
f" скриптов — сверяется только markdown.")
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)