- DECISIONS.md (4040 строк, 65 тем) → decisions/, файл на тему плюс указатель; - буквенные метки решений заменены сквозными Р1–Р234, следствия получили префикс С при прежних номерах: схема букв выродилась до пятибуквенных и сломалась — `АЕАКЛ` была занята и темой 53, и темой 65; - 42 перекрёстные ссылки переписаны под новые номера и стали живыми; где номер означал тему, а слово стояло «решение», формулировка исправлена.
224 lines
12 KiB
Python
224 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""Сверка чужих адресов в прозе скиллов с перечнем их владельца.
|
||
|
||
Судится **упразднённое, а не незнакомое**, и это следует из канона, а не из
|
||
осторожности: список тем открытый — всё, что проект кладёт в `docs/` сверх
|
||
закрытых категорий, законная тема. Значит незнакомое имя опровергнуть нечем, а
|
||
переименование и упразднение ловятся точно: канон, убирая слот, кладёт его в
|
||
карту переездов, и именно она здесь и есть перечень запрещённого. Рядом
|
||
единственная догадка — имя, **почти** совпавшее с каноническим: это опечатка с
|
||
куда большей вероятностью, чем новая тема.
|
||
|
||
Адрес документа принадлежит одному скиллу, а называют его все: `docs/*` стоит
|
||
примерно в сорока местах конвейера, `tasks/ROADMAP.md` — в четырёх местах
|
||
канона. Переименование в каноне до этих мест не доходит.
|
||
|
||
**Почему тут нужна машина, а не аккуратность.** Прогон ревью умеет честно
|
||
деградировать: дома темы нет — в границах покрытия появляется строка «документа в
|
||
проекте нет» с названной ценой. Протухший адрес попадает ровно в эту машинерию —
|
||
файл не открылся, строка напечаталась, и отчёт выглядит добросовестным. То есть
|
||
единственный признак ошибки, на который можно было бы рассчитывать — громкая
|
||
поломка, — деградацией и убран. Здесь он возвращается гейтом.
|
||
|
||
Перечень адресов берётся из **константы владельца** — той самой, по которой он и
|
||
так проверяет раскладку. Второй перечень прозой был бы вторым домом ровно того
|
||
сорта, против которого всё это написано.
|
||
|
||
addresses.py [корень]
|
||
|
||
Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф
|
||
против окружения» — av-dev/shared/axes.md. Значения — в константах ниже.
|
||
"""
|
||
|
||
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/doc-canon/references/changelog-before-merge.md":
|
||
"журнал версий канона до слияния",
|
||
"av-dev/skills/doc-canon/references/changelog-tasks-before-merge.md":
|
||
"журнал версий формата задач до слияния",
|
||
"decisions/": "журнал решений",
|
||
"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.91–0.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/` (`CLAUDE.md`, `.av-dev.toml`),
|
||
# и в перечень имён внутри каталога не идут вовсе. Ветка осталась бы мёртвой
|
||
# молча, поэтому её тут нет: имя служебного файла добавляется ниже поимённо.
|
||
|
||
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
|
||
posix = rel.as_posix()
|
||
if any(posix == k or (k.endswith("/") and posix.startswith(k))
|
||
for k 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)
|