Files
dev-skills/scripts/addresses.py
T
av ed83ec7dc0 задачи: починена смена стадии, разобраны находки ревью плагина
Команда stage была дефектна по шести пунктам, и все шесть подтверждены
прогоном: не звала raw_last (переход оставлял каталог красным), не
переписывала шапку беклога (индекс продолжал объявлять прежнюю стадию),
шла в обход write_config, молча пропускала файлы с непересобираемой метой,
ломалась на беклоге без заголовков и схлопывала полки при первом
объявлении стадии.

Объявление и смена разведены: объявление беклога не трогает вовсе, смена
трогает состав секций только по явному --sections, а слить полки скрипт
не берётся ни в одном случае. Абзац шапки размечен парой «стадия», и
расхождение с конфигом стало обычным дрейфом.

Отказ по недостающей строке индекса запирал запись, пережившую упразднение
роадмапа: edit, close и reopen теперь заводят или пропускают строку сами.
Прочее: регистр stage нормализуется при чтении; --fix снимает мёртвые теги
и у неразобранных записей; move отказывает переставлять сырьё; adopt
держит место сырья; docs.py bump двигает одну запись журнала за раз;
tasks.py получил перечень упразднённых адресов, и гейт наконец видит
собственное упразднение ROADMAP.md.

Запись «Версия 3» переписана по прогону на игрушечном проекте: прежний
порядок шагов был неисполним. Закрыты дыры модели стадий (пересмотр плана
стройки стал сценарием, приёмка отвязана от груминга, from-review,
research и adopt получили развилку по стадии, перечень осей пересчитан) и
находки, старшие этой сессии: review-triage получил режим без метки, три
списка проектных копий сведены к дому с проверяемыми копиями, пять
пересказов правил стали помеченными копиями или ссылками, language.md
перестал объявлять юрисдикцию над чужим плагином.
2026-08-13 15:08:29 +03:00

226 lines
13 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/BACKLOG.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/canon/scripts/docs.py",
"tasks": "av-dev/skills/task-track/scripts/tasks.py",
}
# Журналы: описывают прошлые состояния и задним числом не переписываются.
# Адрес, верный на момент записи, здесь останется навсегда, и это не дрейф.
# Ключ, кончающийся на `/`, — каталог целиком: журнал решений разложен по теме
# на файл, и каждый новый файл в нём — журнал по построению, а не по списку.
JOURNALS = {
"av-dev/skills/canon/references/changelog.md": "журнал версий раскладки",
"av-dev/skills/canon/references/changelog-before-merge.md":
"журнал версий канона до слияния",
"av-dev/skills/canon/references/changelog-tasks-before-merge.md":
"журнал версий формата задач до слияния",
"decisions/": "журнал решений",
"NOTES.md": "рабочие заметки",
}
# Файлы, где упразднённый адрес назван по делу: карта переездов и сценарии
# перевода чужой раскладки. Неизвестные адреса в них проверяются как везде.
RETIRED_OK = {
"av-dev/skills/canon/references/canon.md": "карта упразднённых слотов",
"av-dev/skills/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)}
# Упразднённое собирается у **обоих** владельцев: слоты канона знает
# `docs.py`, части каталога задач — `tasks.py`, и перечень одного из них
# молчал бы про упразднения другого.
retired = {stem(n): why for n, why in {**docs.RETIRED, **tasks.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)