diff --git a/DECISIONS.md b/DECISIONS.md index 8c5b095..25b4cb1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -3350,9 +3350,24 @@ JJJ): у профиля обязан быть один правильный от **Адреса при этом наружу не поехали.** У них владелец есть: раскладку `docs/` держит канон, каталог задач — плагин задач. `shared/` заводится **только для -фактов без владельца**; чужое с владельцем остаётся дома, а сходимость -упоминаний в чужих деревьях проверяется машиной — это следующая работа, записана -в TODO. +фактов без владельца**; чужое с владельцем остаётся дома, а сходимость упоминаний +в чужих деревьях проверяет машина — `scripts/addresses.py`, тем же заходом. + +**Что выяснилось при написании чекера: судить незнакомое нельзя.** Первый прогон +дал шесть находок, и три из них были не дрейфом, а свойством канона: `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) — и видно не только, что порог верен, но и насколько + он не на грани. diff --git a/README.md b/README.md index 182c51e..134d41b 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ claude plugin uninstall <плагин>@av-dev-skills --scope project /skills//scripts/ tasks.py, docs.py, openspec.py /agents/ charter'ы сабагентов shared/ дома правил, общих для нескольких плагинов -scripts/ проверки репозитория: копии, диаграммы, фронтматтеры +scripts/ проверки репозитория: копии, адреса, диаграммы, фронтматтеры pyproject.toml линтеры скриптов, только для этого репозитория 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 — картинок в репозитории нет. @@ -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), ставится один раз на клон: ``` @@ -393,6 +427,7 @@ lefthook run pre-commit # прогнать руками, не коммитя | --- | --- | --- | --- | | фронтматтеры | правка `*.md` | весь репозиторий | миллисекунды | | копии правил | правка `*.md` | весь репозиторий | миллисекунды | +| адреса документов | **каждый коммит** | весь репозиторий | ~0.07 с | | диаграммы | правка `*.md` | staged-файлы | ~1 с на файл | | `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды | | `pyrefly check` | правка `*.py` | staged-файлы | доли секунды | @@ -402,10 +437,13 @@ Glob разводит две половины: коммит, трогающий **Судятся staged-файлы, а не рабочее дерево** — гейт обязан проверять то, что уедет в историю, а не то, что случайно лежит рядом на диске. Исключений два, и -оба про существо, а не про удобство: `copies.py` сверяет копию с домом, а дом -лежит в другом файле, которого в индексе может не быть (список staged дал бы -«копии дословны» ровно там, где правка дома их и разошлась), а `frontmatter.py` -обходит весь репозиторий за сотые доли секунды — экономить тут нечего. +три, и все про существо, а не про удобство: `copies.py` сверяет копию с домом, а +дом лежит в другом файле, которого в индексе может не быть (список staged дал бы +«копии дословны» ровно там, где правка дома их и разошлась); `frontmatter.py` +обходит весь репозиторий за сотые доли секунды — экономить тут нечего; +`addresses.py` идёт **без glob вовсе**, потому что сводит две стороны: перечень +адресов лежит в `*.py` владельца, а упоминания — в `*.md` соседей, и коммит с +переименованием документа трогает только первую. **`ruff` чинит безопасное сам, и починка доносится до этого же коммита** (`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в diff --git a/REMAINING.md b/REMAINING.md index 551e315..38ed191 100644 --- a/REMAINING.md +++ b/REMAINING.md @@ -114,7 +114,8 @@ check` сверяет версию, но не то, что миграционн словарь строится каждый раз заново из спек и архитектуры. Цена не измерена. **Смысловые дубли ловит только агент.** `docs.py` видит раскладку, но не то, что -`docs/specs/recognition.md` описывает то же, что capability `recognition`. +раздел `docs/architecture.md` описывает поведение, уже записанное capability +`recognition`. Граница объявляется вслух в каждом отчёте — это единственная защита от «соблюдено» на проекте с тремя лишними файлами. diff --git a/TODO.md b/TODO.md index 230fc41..9523083 100644 --- a/TODO.md +++ b/TODO.md @@ -110,27 +110,7 @@ сколько из пяти стадий реально смотрятся глазами. 1028 строк, и весь автоматический этап держится на них -## 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. Обкатка +## 5. Обкатка - [ ] один-два цикла healthlog на новом процессе; наблюдение к первой обкатке — не выродились ли «границы покрытия» в шаблон (REMAINING, «Открытые diff --git a/lefthook.yml b/lefthook.yml index c674451..0d2d601 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -32,6 +32,15 @@ pre-commit: glob: "*.md" run: python3 scripts/copies.py + # Без glob намеренно, и это третье исключение из правила «судим staged». + # Проверка сводит две стороны: перечень адресов лежит в константе скрипта + # владельца (`.py`), упоминания — в прозе плагинов (`.md`). Коммит, где + # переименован документ канона, трогает только первую сторону: по глобу + # `*.md` он бы проверку не разбудил, а расходится в нём именно вторая. + # Весь обход — семь сотых секунды. + - name: адреса документов + run: python3 scripts/addresses.py + # Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим # chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого # скрипта: репозиторий целиком — 3 секунды, один файл — одна. diff --git a/pyproject.toml b/pyproject.toml index ef1c51c..a4d3781 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ project-includes = [ "av-dev-tasks/skills/tasks/scripts/tasks.py", "av-dev-docs/skills/canon/scripts/docs.py", "av-dev-pipeline/skills/openspec/scripts/openspec.py", + "scripts/addresses.py", "scripts/copies.py", "scripts/diagrams.py", "scripts/frontmatter.py", diff --git a/scripts/addresses.py b/scripts/addresses.py new file mode 100644 index 0000000..fceba91 --- /dev/null +++ b/scripts/addresses.py @@ -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"(? 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)