Files
dev-skills/scripts/addresses.py
T
av bf6a173115 журнал решений: разложен по теме на файл, метки решений стали номерами
- DECISIONS.md (4040 строк, 65 тем) → decisions/, файл на тему плюс указатель;
- буквенные метки решений заменены сквозными Р1–Р234, следствия получили
  префикс С при прежних номерах: схема букв выродилась до пятибуквенных и
  сломалась — `АЕАКЛ` была занята и темой 53, и темой 65;
- 42 перекрёстные ссылки переписаны под новые номера и стали живыми; где номер
  означал тему, а слово стояло «решение», формулировка исправлена.
2026-08-13 12:40:56 +03:00

224 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/*` стоит
примерно в сорока местах конвейера, `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.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/` (`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)