Files
dev-skills/scripts/addresses.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

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