адреса чужих документов сверяются с перечнем владельца
Адрес принадлежит одному плагину, а называют его все: docs/* стоит в сорока местах конвейера, tasks/ROADMAP.md — в четырёх местах канона. Переименование в каноне до них не доходит, и заметить это нечем: протухший адрес попадает в механизм честной деградации ревью и выходит правдоподобной строкой «документа в проекте нет», а не поломкой. Перечень берётся из константы владельца — той, по которой он и так проверяет раскладку. Судится упразднённое, а не незнакомое: список тем канона открытый, и «нет такого имени» опровергнуть нечем; зато карта переездов RETIRED и есть перечень запрещённого. Рядом одна догадка — почти совпавшее имя как опечатка, порог замерен (законные до 0.64, опечатки от 0.91). Первый прогон: одна настоящая находка — REMAINING иллюстрировал смысловой дубль адресом docs/specs/, упразднённым в версии 1 канона. В гейте без glob: перечень лежит в .py, упоминания в .md, и коммит с переименованием трогает только первую сторону.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Сверка чужих адресов в прозе плагинов с перечнем их владельца.
|
||||
|
||||
Судится **упразднённое, а не незнакомое**, и это следует из канона, а не из
|
||||
осторожности: список тем открытый — всё, что проект кладёт в `docs/` сверх
|
||||
закрытых категорий, законная тема. Значит незнакомое имя опровергнуть нечем, а
|
||||
переименование и упразднение ловятся точно: канон, убирая слот, кладёт его в
|
||||
карту переездов, и именно она здесь и есть перечень запрещённого. Рядом
|
||||
единственная догадка — имя, **почти** совпавшее с каноническим: это опечатка с
|
||||
куда большей вероятностью, чем новая тема.
|
||||
|
||||
Адрес документа принадлежит одному плагину, а называют его все: `docs/*` стоит
|
||||
примерно в сорока местах `av-dev-pipeline`, `tasks/ROADMAP.md` — в четырёх местах
|
||||
`av-dev-docs`. Переименование в каноне до этих мест не доходит.
|
||||
|
||||
**Почему тут нужна машина, а не аккуратность.** Прогон ревью умеет честно
|
||||
деградировать: дома темы нет — в границах покрытия появляется строка «документа в
|
||||
проекте нет» с названной ценой. Протухший адрес попадает ровно в эту машинерию —
|
||||
файл не открылся, строка напечаталась, и отчёт выглядит добросовестным. То есть
|
||||
единственный признак ошибки, на который можно было бы рассчитывать — громкая
|
||||
поломка, — деградацией и убран. Здесь он возвращается гейтом.
|
||||
|
||||
Перечень адресов берётся из **константы владельца** — той самой, по которой он и
|
||||
так проверяет раскладку. Второй перечень прозой был бы вторым домом ровно того
|
||||
сорта, против которого всё это написано.
|
||||
|
||||
addresses.py [корень]
|
||||
|
||||
Коды выхода — общий словарь скриптов av-dev:
|
||||
0 сошлось
|
||||
1 дрейф: неизвестный или упразднённый адрес
|
||||
2 ошибка употребления
|
||||
3 окружение: не тот каталог, перечень владельца недоступен
|
||||
4 внутренний сбой
|
||||
"""
|
||||
|
||||
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-docs/skills/canon/scripts/docs.py",
|
||||
"tasks": "av-dev-tasks/skills/tasks/scripts/tasks.py",
|
||||
}
|
||||
|
||||
# Журналы: описывают прошлые состояния и задним числом не переписываются.
|
||||
# Адрес, верный на момент записи, здесь останется навсегда, и это не дрейф.
|
||||
JOURNALS = {
|
||||
"av-dev-docs/skills/canon/references/changelog.md": "журнал версий канона",
|
||||
"DECISIONS.md": "журнал решений",
|
||||
"HISTORY.md": "журнал работ",
|
||||
"NOTES.md": "рабочие заметки",
|
||||
}
|
||||
|
||||
# Файлы, где упразднённый адрес назван по делу: карта переездов и сценарии
|
||||
# перевода чужой раскладки. Неизвестные адреса в них проверяются как везде.
|
||||
RETIRED_OK = {
|
||||
"av-dev-docs/skills/canon/references/canon.md": "карта упразднённых слотов",
|
||||
"av-dev-docs/skills/canon/SKILL.md": "adopt: что где искать в чужой раскладке",
|
||||
"av-dev-tasks/skills/tasks/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/`
|
||||
называют одно. Скрытые имена (`.pm.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/.pm.json` объявлен обязательным файлом вне раскладки.
|
||||
docs_names |= {stem(Path(p).name) for p in docs.REQUIRED if p.startswith("docs/")}
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user