адреса чужих документов сверяются с перечнем владельца

Адрес принадлежит одному плагину, а называют его все: docs/* стоит в
сорока местах конвейера, tasks/ROADMAP.md — в четырёх местах канона.
Переименование в каноне до них не доходит, и заметить это нечем:
протухший адрес попадает в механизм честной деградации ревью и выходит
правдоподобной строкой «документа в проекте нет», а не поломкой.

Перечень берётся из константы владельца — той, по которой он и так
проверяет раскладку. Судится упразднённое, а не незнакомое: список тем
канона открытый, и «нет такого имени» опровергнуть нечем; зато карта
переездов RETIRED и есть перечень запрещённого. Рядом одна догадка —
почти совпавшее имя как опечатка, порог замерен (законные до 0.64,
опечатки от 0.91).

Первый прогон: одна настоящая находка — REMAINING иллюстрировал
смысловой дубль адресом docs/specs/, упразднённым в версии 1 канона.

В гейте без glob: перечень лежит в .py, упоминания в .md, и коммит с
переименованием трогает только первую сторону.
This commit is contained in:
av
2026-08-09 15:12:46 +03:00
parent c6be879831
commit 872732989a
7 changed files with 302 additions and 31 deletions
+27 -3
View File
@@ -3350,9 +3350,24 @@ JJJ): у профиля обязан быть один правильный от
**Адреса при этом наружу не поехали.** У них владелец есть: раскладку `docs/` **Адреса при этом наружу не поехали.** У них владелец есть: раскладку `docs/`
держит канон, каталог задач — плагин задач. `shared/` заводится **только для держит канон, каталог задач — плагин задач. `shared/` заводится **только для
фактов без владельца**; чужое с владельцем остаётся дома, а сходимость фактов без владельца**; чужое с владельцем остаётся дома, а сходимость упоминаний
упоминаний в чужих деревьях проверяется машинойэто следующая работа, записана в чужих деревьях проверяет машина`scripts/addresses.py`, тем же заходом.
в TODO.
**Что выяснилось при написании чекера: судить незнакомое нельзя.** Первый прогон
дал шесть находок, и три из них были не дрейфом, а свойством канона: `docs/**`
шаблон, а `docs/accessibility.md` в двух местах — пример **своей темы проекта**,
которую канон разрешает заводить произвольно. Список тем открытый, значит
незнакомое имя опровергнуть нечем, и проверка «есть ли такой документ у
владельца» ловила бы законное. Переименование при этом ловится точно и по другому
основанию: канон, убирая слот, кладёт его в карту переездов `RETIRED` — она и
есть перечень запрещённого. Рядом одна догадка: имя, почти совпавшее с
каноническим, читается как опечатка. Порог замерен по репозиторию — законные
имена дают до 0.64, опечатки от 0.91, и между ними пусто.
Четвёртая находка оказалась настоящей: `REMAINING.md` иллюстрировал смысловой
дубль адресом `docs/specs/recognition.md` — слотом, упразднённым в версии 1
канона, то есть при десяти нынешних.
Пример, который сам протух, — ровно то, ради чего чекер и писался.
### Что из этого следует ### Что из этого следует
@@ -3368,3 +3383,12 @@ JJJ): у профиля обязан быть один правильный от
дом, последствие — нет: оно знает про место, а место про правило знать не дом, последствие — нет: оно знает про место, а место про правило знать не
обязано. Дом, вобравший последствия, становится реестром потребителей и обязано. Дом, вобравший последствия, становится реестром потребителей и
устаревает быстрее их всех. устаревает быстрее их всех.
182. **Проверять надо запрещённое, а не незнакомое, когда словарь открыт.**
Открытый список делает «нет такого имени» неопровержимым, и проверка на
принадлежность перечню начинает ловить законное. Ловится ровно то, что
владелец объявил упразднённым: карта переездов — не побочный артефакт
миграции, а перечень запрещённого, и стоит она ровно там, где нужна.
183. **Замер порога записывается рядом с порогом.** Число, выбранное на глаз,
через месяц неотличимо от подогнанного под один случай. Обе стороны разрыва
названы (0.64 и 0.91) — и видно не только, что порог верен, но и насколько
он не на грани.
+44 -6
View File
@@ -259,7 +259,7 @@ claude plugin uninstall <плагин>@av-dev-skills --scope project
<plugin>/skills/<skill>/scripts/ tasks.py, docs.py, openspec.py <plugin>/skills/<skill>/scripts/ tasks.py, docs.py, openspec.py
<plugin>/agents/ charter'ы сабагентов <plugin>/agents/ charter'ы сабагентов
shared/ дома правил, общих для нескольких плагинов shared/ дома правил, общих для нескольких плагинов
scripts/ проверки репозитория: копии, диаграммы, фронтматтеры scripts/ проверки репозитория: копии, адреса, диаграммы, фронтматтеры
pyproject.toml линтеры скриптов, только для этого репозитория pyproject.toml линтеры скриптов, только для этого репозитория
lefthook.yml гейт коммита: проверки документов lefthook.yml гейт коммита: проверки документов
``` ```
@@ -352,6 +352,40 @@ uv run python scripts/copies.py # 0 сошлось, 1 расхождение
на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит — на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит —
копию, которую забыли пометить: помечать — по-прежнему решение человека. копию, которую забыли пометить: помечать — по-прежнему решение человека.
## Проверка адресов документов
Адрес документа принадлежит одному плагину, а называют его все: `docs/*` стоит
примерно в сорока местах конвейера, `tasks/ROADMAP.md` — в четырёх местах канона.
Переименование в каноне до этих мест само не доходит.
```
python3 scripts/addresses.py # весь репозиторий
# 0 сошлось, 1 упразднённый адрес или опечатка, 3 перечень владельца недоступен
```
**Зачем машина, а не аккуратность.** Прогон ревью умеет честно деградировать:
дома темы нет — в границах покрытия появляется строка «документа в проекте нет»
с названной ценой. Протухший адрес попадает ровно в эту машинерию и выходит
**правдоподобным отчётом**, а не поломкой. Громкий признак ошибки деградацией
убран, и здесь он возвращается гейтом.
Перечень берётся из **константы владельца** — той, по которой он и так проверяет
раскладку (`docs.py`, `tasks.py`). Второй перечень прозой был бы вторым домом
ровно того сорта, против которого написан канон.
Судится **упразднённое, а не незнакомое**, и это следует из канона: список тем
открытый, всё, что проект кладёт в `docs/` сверх закрытых категорий, — законная
тема, и опровергнуть её нечем. Зато переименование ловится точно: канон, убирая
слот, кладёт его в карту переездов, и она здесь и есть перечень запрещённого.
Рядом единственная догадка — имя, **почти** совпавшее с каноническим: `securty`
это опечатка вероятнее, чем новая тема. Порог замерен по репозиторию: законные
имена дают до 0.64, опечатки — от 0.91.
Не проверяются журналы (они описывают прошлые состояния и задним числом не
переписываются), адреса `openspec/*` (раскладка чужого инструмента, владельца у
нас нет) и упоминания в комментариях скриптов — сверяется только markdown. Эти
границы скрипт печатает сам.
## Проверка диаграмм ## Проверка диаграмм
Диаграммы `mermaid` живут исходником в markdown — картинок в репозитории нет. Диаграммы `mermaid` живут исходником в markdown — картинок в репозитории нет.
@@ -381,7 +415,7 @@ uv run python scripts/diagrams.py A.md B.md # только названные
## Гейт коммита ## Гейт коммита
Все пять проверок стоят в `pre-commit` через [lefthook](https://lefthook.dev) — Все шесть проверок стоят в `pre-commit` через [lefthook](https://lefthook.dev) —
конфиг в [lefthook.yml](lefthook.yml), ставится один раз на клон: конфиг в [lefthook.yml](lefthook.yml), ставится один раз на клон:
``` ```
@@ -393,6 +427,7 @@ lefthook run pre-commit # прогнать руками, не коммитя
| --- | --- | --- | --- | | --- | --- | --- | --- |
| фронтматтеры | правка `*.md` | весь репозиторий | миллисекунды | | фронтматтеры | правка `*.md` | весь репозиторий | миллисекунды |
| копии правил | правка `*.md` | весь репозиторий | миллисекунды | | копии правил | правка `*.md` | весь репозиторий | миллисекунды |
| адреса документов | **каждый коммит** | весь репозиторий | ~0.07 с |
| диаграммы | правка `*.md` | staged-файлы | ~1 с на файл | | диаграммы | правка `*.md` | staged-файлы | ~1 с на файл |
| `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды | | `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды |
| `pyrefly check` | правка `*.py` | staged-файлы | доли секунды | | `pyrefly check` | правка `*.py` | staged-файлы | доли секунды |
@@ -402,10 +437,13 @@ Glob разводит две половины: коммит, трогающий
**Судятся staged-файлы, а не рабочее дерево** — гейт обязан проверять то, что **Судятся staged-файлы, а не рабочее дерево** — гейт обязан проверять то, что
уедет в историю, а не то, что случайно лежит рядом на диске. Исключений два, и уедет в историю, а не то, что случайно лежит рядом на диске. Исключений два, и
оба про существо, а не про удобство: `copies.py` сверяет копию с домом, а дом три, и все про существо, а не про удобство: `copies.py` сверяет копию с домом, а
лежит в другом файле, которого в индексе может не быть (список staged дал бы дом лежит в другом файле, которого в индексе может не быть (список staged дал бы
«копии дословны» ровно там, где правка дома их и разошлась), а `frontmatter.py` «копии дословны» ровно там, где правка дома их и разошлась); `frontmatter.py`
обходит весь репозиторий за сотые доли секунды — экономить тут нечего. обходит весь репозиторий за сотые доли секунды — экономить тут нечего;
`addresses.py` идёт **без glob вовсе**, потому что сводит две стороны: перечень
адресов лежит в `*.py` владельца, а упоминания — в `*.md` соседей, и коммит с
переименованием документа трогает только первую.
**`ruff` чинит безопасное сам, и починка доносится до этого же коммита** **`ruff` чинит безопасное сам, и починка доносится до этого же коммита**
(`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в (`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в
+2 -1
View File
@@ -114,7 +114,8 @@ check` сверяет версию, но не то, что миграционн
словарь строится каждый раз заново из спек и архитектуры. Цена не измерена. словарь строится каждый раз заново из спек и архитектуры. Цена не измерена.
**Смысловые дубли ловит только агент.** `docs.py` видит раскладку, но не то, что **Смысловые дубли ловит только агент.** `docs.py` видит раскладку, но не то, что
`docs/specs/recognition.md` описывает то же, что capability `recognition`. раздел `docs/architecture.md` описывает поведение, уже записанное capability
`recognition`.
Граница объявляется вслух в каждом отчёте — это единственная защита от Граница объявляется вслух в каждом отчёте — это единственная защита от
«соблюдено» на проекте с тремя лишними файлами. «соблюдено» на проекте с тремя лишними файлами.
+1 -21
View File
@@ -110,27 +110,7 @@
сколько из пяти стадий реально смотрятся глазами. 1028 строк, и весь сколько из пяти стадий реально смотрятся глазами. 1028 строк, и весь
автоматический этап держится на них автоматический этап держится на них
## 5. Стык плагинов — проверка адресов ## 5. Обкатка
Не блокирует ничего и делается после раздела 1: писать чекер лучше, когда
`adopt` на живом проекте покажет, какие адреса называются вслух. Правило
обращения к соседу дом уже получило (`shared/plugin-boundary.md`, решение 54) —
осталась вторая половина.
- [ ] `scripts/addresses.py`: владелец отдаёт перечень своих адресов машинно
(константа, по которой он и так проверяет раскладку, — `docs.py` и
`tasks.py`), чекер грепает чужие деревья и падает на адресе, которого у
владельца нет. **Зачем машина, а не внимание:** протухший адрес в проходе
ревью попадает в механизм честной деградации и выходит правдоподобной
строкой «документа в проекте нет» (решение 54, вывод 179)
- [ ] форма адреса нормализуется до имени темы: `docs/security.md`,
`docs/security/` и `docs/security.*` — одна запись. Иначе чекер начнёт
требовать выбора формы, которую канон сознательно оставляет проекту
- [ ] упоминание **отставленного** адреса в чужом плагине — тоже находка:
карта RETIRED у `docs.py` уже есть, и сейчас её не сверяет никто. Журнал
версий из проверки исключается — задним числом он не переписывается
## 6. Обкатка
- [ ] один-два цикла healthlog на новом процессе; наблюдение к первой обкатке — - [ ] один-два цикла healthlog на новом процессе; наблюдение к первой обкатке —
не выродились ли «границы покрытия» в шаблон (REMAINING, «Открытые не выродились ли «границы покрытия» в шаблон (REMAINING, «Открытые
+9
View File
@@ -32,6 +32,15 @@ pre-commit:
glob: "*.md" glob: "*.md"
run: python3 scripts/copies.py run: python3 scripts/copies.py
# Без glob намеренно, и это третье исключение из правила «судим staged».
# Проверка сводит две стороны: перечень адресов лежит в константе скрипта
# владельца (`.py`), упоминания — в прозе плагинов (`.md`). Коммит, где
# переименован документ канона, трогает только первую сторону: по глобу
# `*.md` он бы проверку не разбудил, а расходится в нём именно вторая.
# Весь обход — семь сотых секунды.
- name: адреса документов
run: python3 scripts/addresses.py
# Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим # Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим
# chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого # chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого
# скрипта: репозиторий целиком — 3 секунды, один файл — одна. # скрипта: репозиторий целиком — 3 секунды, один файл — одна.
+1
View File
@@ -59,6 +59,7 @@ project-includes = [
"av-dev-tasks/skills/tasks/scripts/tasks.py", "av-dev-tasks/skills/tasks/scripts/tasks.py",
"av-dev-docs/skills/canon/scripts/docs.py", "av-dev-docs/skills/canon/scripts/docs.py",
"av-dev-pipeline/skills/openspec/scripts/openspec.py", "av-dev-pipeline/skills/openspec/scripts/openspec.py",
"scripts/addresses.py",
"scripts/copies.py", "scripts/copies.py",
"scripts/diagrams.py", "scripts/diagrams.py",
"scripts/frontmatter.py", "scripts/frontmatter.py",
+218
View File
@@ -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.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/`
называют одно. Скрытые имена (`.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)