#!/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.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"(? 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 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)