Compare commits

..
15 Commits
Author SHA1 Message Date
av 600aba5ee3 закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
2026-07-28 10:16:27 +03:00
av 29d29740f5 решения об инструменте перенесены из TOOL.md
- TOOL.md в каноне удалён как выполнивший задачу стартовой точки; всё,
  что из него осталось живым, переехало сюда
- правило одной зависимости и решение про один бинарь стоят теперь сами
  по себе, а не со ссылкой на удалённый файл
- в известные остатки добавлены два открытых вопроса оттуда: способ
  раздачи бинарей и опечатка convey в текстах
2026-07-28 10:05:33 +03:00
av a597805f3c манифест набора переименован в .conventions-suite.toml
- оба манифеста стали данными, которые инструмент переписывает целиком,
  так что и наборный получает точку в начале: он служебный файл, а не
  документ репозитория
- переименование механическое, одна константа manifest.Name; литералы в
  фикстурах и ссылки в README.md и CLAUDE.md обновлены следом
2026-07-28 09:55:26 +03:00
av 92bd1f463d манифесты стали данными, заведён convy sync
- убраны комментарии из suite.toml и .conventions.toml: файл, который
  машина переписывает, комментарий через круг не проносит; объяснения
  ушли в README рядом, который suite init теперь заводит
- удалена текстовая правка манифеста целиком — 520 строк ручного
  лексера TOML вместе со всем классом ошибок порчи данных
- запись идёт из структур энкодером; ключ, которого инструмент не
  знает, запись останавливает, а не теряется молча
- convy sync сверяет манифест и подводит под него раскладку файлов:
  чего не хватает — собирает, что осиротело — удаляет, копию с
  локальной частью не трогает никогда
2026-07-28 09:45:10 +03:00
av b6b0976c19 исправлены находки ревью проектной стороны
- манифест читается так, как записан: решётка внутри строки не открывает
  комментарий, скобка внутри комментария не закрывает массив, имя внутри
  комментария не становится подпиской; новый ключ встаёт после массива,
  а не внутрь него
- всё записываемое проходит через manifest.Quote — обратный слэш в пути
  делал файл, который инструмент сам не читает
- маркер локальной части переехал в doc и пропускает огороженные блоки:
  процитированный в примере маркер больше не считается границей, а копия
  без маркера не перезаписывается молча
- лишний позиционный аргумент отсекается: flag прекращал разбор и прятал
  флаги после себя, из-за чего pull, list и check игнорировали --for
- заведены тесты проверок копий, включая молчание на исправной копии
2026-07-27 21:16:29 +03:00
av 23d88c4048 проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
2026-07-27 20:42:18 +03:00
av 4615de6e86 заведены README.md и CLAUDE.md
- README описывает термины, команды, два режима и вывод проверки; модель не
  пересказывается — истина остаётся в репозитории набора
- CLAUDE.md фиксирует распределение языков, инварианты кода и решения, которые
  уже приняты и не пересматриваются без просьбы
- отдельным разделом перечислены известные остатки, чтобы их не искали заново
2026-07-27 11:58:49 +03:00
av 8331aa1ca5 suite rule и suite list
- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки
  раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом
  с тем, которое оно уточняет
- ступень называется категорией (requirement, prohibition, ...), а не словом
  языка, поэтому вызывающему не нужно знать, на каком языке записан набор
- suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор
  слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды
- слои темы теперь всегда возвращаются базовым вперёд
2026-07-27 11:36:19 +03:00
av 5321fba89d suite retire и проверка словаря в документе для читателя
- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой
  с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными
  ссылками отклоняется с перечнем мест
- добавлена проверка META-30: короткое описание языка обязано называть все
  слова словаря, иначе читатель копии толкует их по памяти
- retired-раздел манифеста больше не считается объявлением пути: снятый
  префикс означает, что файл ушёл вместе с ним
2026-07-27 10:51:46 +03:00
av b29b5b5e6f suite init и suite add: заведение набора и конвенции в двух режимах
- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет
  файл конвенции и вправляет запись в suite.toml, сохраняя комментарии
- без аргументов команды спрашивают поля с подсказками, с флагами берут всё
  сразу и не спрашивают ничего; без терминала пустой вызов отказывает
- строка о версии языка генерируется из словаря набора, поэтому созданный файл
  проходит suite check без правок
- починено разрешение extends: короткая форма бралась по суффиксу и могла
  указать на сам файл; теперь неоднозначность либо избегается при записи, либо
  сообщается ошибкой
2026-07-27 10:40:57 +03:00
av 709157237d suite check: направление ссылок внутри темы проверяется во всём документе
- ссылка на слой своей темы с объявленной осью — ошибка где угодно, а не только
  в норме: гарантированно присутствует в копии один базовый слой
- ссылки на чужую тему вне нормы остаются разрешёнными по META-20
2026-07-27 10:27:30 +03:00
av 51d2050200 suite check: закрыты пробелы в проверках шапки, иерархии и словаря
- документ без темы: ошибка, если такой не один или если он несёт ключи слоя —
  иначе конвенция, потерявшая topic, молча лишалась всех проверок распространения
- иерархия заголовков: один заголовок первого уровня, без перескоков через уровень
- имя темы: латиница и пригодность для имени файла — ошибкой, kebab-case —
  предупреждением
- сценарные связки чужого словаря ловятся в начале строки, где их ставит
  сценарный блок, а не в середине фразы, где это чаще SQL
2026-07-27 10:16:15 +03:00
av b2d07ae55d комментарии и сообщения переведены на английский
- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском
- по-русски остались только литералы словаря ru и содержимое фикстур: это
  данные под проверкой, а не текст инструмента
- согласование числительных в итоге упростилось до английского plural
2026-07-27 10:04:27 +03:00
av 0b8cc125b3 suite check: заголовок правила больше не разбирается как ссылка
- заголовок правило объявляет, а не ссылается на него: при опечатке в префиксе
  одна ошибка формы давала вторую находку о неразрешённой ссылке
- числительные в итоге согласованы с существительным
2026-07-27 09:55:11 +03:00
av ccf046fb7b suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками
- проверки формы правила, распространения, ссылок и самого suite.toml
- словарь языка живёт в бинаре реестром «версия × естественный язык», темы и
  префиксы берутся только из манифеста
2026-07-27 09:53:07 +03:00
48 changed files with 9951 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.
## Язык
Общение по репозиторию — на русском. Внутри репозитория язык распределён так, и
это распределение проверяется глазами при ревью:
- **английский** — комментарии в коде, имена, тексты ошибок, вывод CLI,
сообщения и имена тестов;
- **русский** — `README.md`, `CLAUDE.md`, сообщения коммитов;
- **язык проверяемого набора** — содержимое тестовых фикстур и литералы
словарей в `internal/lang`. Это данные под проверкой, а не текст инструмента,
и переводить их нельзя: сломается то, что они проверяют.
Слова словаря законно попадают внутрь английских сообщений, потому что
подставляются из набора: `rule GTIM-1 has no ПОЧЕМУ block`. Инструмент говорит
по-английски и цитирует то, чем записан проверяемый набор.
## Что это
CLI для управления конвенциями. Модель здесь не описывается: она живёт в
репозитории набора `dev-conventions``README.md` (набор и копии),
`LANGUAGE.md` (форма правила и список проверок), `GUIDE.md` (ведение набора,
префикс META). При расхождении истина там, а не в коде и не здесь.
Решения о самом инструменте живут здесь. Раньше они лежали в `TOOL.md`
канона — стартовой точке разработки; она свою задачу выполнила и удалена,
а всё, что из неё осталось живым, перенесено ниже.
Пользовательская сторона — в `README.md`. Ниже — то, что нужно знать, правя код.
## Раскладка
```
internal/lang словарь: реестр «версия языка × естественный язык»
internal/source ссылки между уровнями: путь на диске, git-репозиторий
internal/manifest оба манифеста: чтение и запись
internal/doc разбор документа: шапка, области правил, блоки
internal/suite сборка набора в память, отбор слоёв под компонент
internal/project сборка копий в проекте: разделы, маркер, READING.md
internal/check проверки: manifest, form, spread, links
internal/cli команды, диалог, два режима
```
Зависимость одна — `BurntSushi/toml`, и вторую не заводить. `Undecoded()`
этого парсера бесплатно ловит опечатки в ключах манифеста, а инструмент,
который копирует файлы и разбирает markdown, не имеет права тянуть за собой
дерево чужого кода.
Бинарь тоже один, с подкомандой `suite`. Разделять на два дешевле, если
задачи разъедутся, но пока они не разъехались.
## Инварианты кода
- **В коде нет ни одной темы, ни одного префикса, ни одного пути набора.** Всё
это приходит из манифеста. Список проверяемых файлов берётся из
`[prefixes.live]`, а не из дерева директорий: это и есть граница «язык
употребляет» против «язык цитирует».
- **Словарь зашит в бинарь** по паре «версия языка × естественный язык», а не
объявляется в манифесте. Когда спецификация языка уедет в отдельный
репозиторий и там появятся спеки словарей, источником станут они —
подменяется `registry`, тип `Vocabulary` и проверки поверх него не трогаются.
- **Что инструмент пишет, инструмент принимает.** Набор, созданный `suite init`,
`add` и `rule`, обязан проходить `suite check` без правок. Это проверяет
`checkClean` в `internal/cli`; ломать инвариант нельзя.
- **Манифест — данные.** Оба манифеста декодируются в структуры и пишутся
обратно энкодером целиком. Комментариев в них нет: файл, который машина
переписывает, комментарий через круг не проносит, и вид, что проносит, стоит
этого комментария в день, когда никто не смотрит. Объяснения — в соседних
файлах, которых ни одна команда не касается.
- **Непонятый ключ останавливает запись.** Раз запись идёт из структур, ключ,
которого в них нет, при сохранении исчез бы. `manifest.save` отказывается,
называя ключ: это единственный исход, который его не теряет и не прячет.
- **Разбор опирается на разметку, а не на суждение.** Область правила — от
заголовка до следующего заголовка любого уровня. Метка открывает блок только
первой в абзаце и полужирным. Огороженные блоки кода исключаются везде;
инлайн-код вырезается там, где ищутся ссылки, и не вырезается там, где ищутся
пути канона. Маркер локальной части — то же самое: `doc.LocalMarker` один на
весь инструмент, `doc.Marker()` пропускает огороженные блоки, потому что
конвенция о ведении копий этот маркер цитирует.
- **Уровень называется ссылкой, а не путём.** Проект ссылается на набор, набор
на язык; `source.Ref` разбирает ссылку, `source.Open` отдаёт директорию,
которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево:
третий (rclone, дерево по https) ожидается, и отказ обязан сначала сказать,
чем ссылку сочли, и только потом — что не так.
- **Документ самоуправления объявлен, а не угадан.** `governance` в манифесте
называет файл, который написан языком конвенций, но не принадлежит теме.
Угадывание пробовали — «он один» и «у него нет ключей оси» верны и для
набора, у которого единственная конвенция потеряла `topic`, а потеря эта
дорогая: файл сохраняет все проверки формы и тихо теряет все проверки об
отъезде к потребителю.
- **Проверки формы не знают про набор.** `checkRules`, `checkVersionLine`,
`checkModalsOutside` и прочие принимают `lang.Vocabulary`, а не `*suite.Suite`
— иначе `convy check` в проекте пришлось бы писать заново. Копия несёт язык
строкой о версии, и `lang.Recognize` читает его оттуда: манифеста рядом нет.
## Решения, которые уже приняты
Их не пересматривают без явной просьбы — каждое обсуждалось и стоило времени.
- **Порядок правил в файле — по читаемости, а не по номерам.** Номер стабилен и
не переиспользуется, поэтому «порядок по номерам» означал бы «порядок по
времени написания» навсегда. Проверяется сплошность нумерации, а не
возрастание.
- **Ссылка на чужую тему вне нормы разрешена** (META-20): обоснование,
потерявшее адресата, деградирует честно. Внутри своей темы ссылаться можно
только в базовый слой, и это проверяется во всём документе, а не только в
норме: гарантированно присутствует в копии один базовый слой.
- **Заголовок правила — объявление, а не ссылка.** При разборе ссылок строки
заголовков пропускаются.
- **Лок-файла, `push`, отчёта о расхождении и перенумерации не будет.** Модель
отвергает каждое явно.
- **Кэша источника нет.** Клон делается заново и удаляется вместе с `Tree`.
Кэш экономит второй клон и возвращает вопрос, что в нём протухло; на «что
было в прошлый раз» отвечает git потребителя. Если станет дорого, кэш
прячется за `source.Tree` и наружу не виден.
- **`file://` — это git, а не директория.** Простой путь уже означает «эта
директория, как она лежит», вместе с грязным рабочим деревом; `file://`
означает «тот же репозиторий в закоммиченном виде». Ради этой разницы оба
написания и существуют — и ради неё же git-транспорт тестируется без сети.
- **Слой ниже первого становится разделом.** Заголовки опускаются на уровень,
строка о версии языка выбрасывается у всех, кроме первого. Расширение
реализует и сужает базу, а не стоит рядом с ней, — поэтому правила базы в
копии на `###`, а правила языкового слоя на `####`. Проверка копий уровень
заголовка правила не требует, лестницу заголовков — требует.
- **`convy check` до набора не дотягивается.** Форма правила одна и та же,
локальные правила на `X` записаны по ней же, а проверять своё нужно без сети
и без знания, откуда копии приехали.
- **Целостность набора проверяется локально.** Когда `[language] source`
заполнен, `suite check` не тянет описание языка и пропускает проверки
документов о языке (META-30 в том числе), проверяя вместо этого саму ссылку.
Проверка гоняется на каждой правке и в сеть ходить не должна. Пропуск
объявляется строкой в выводе: молча не выполненная проверка читается ровно
как пройденная.
- **Комментариев в манифестах не будет.** Пробовали держать их текстовой
правкой — вышло четыре случая порчи данных подряд: комментарий с кавычками
становился подпиской, скобка в комментарии обрезала массив. Формат с
сохранением комментариев при записи (`go-toml-edit`, YAML через `yaml.Node`)
отвергнут как усложнение под задачу, которой нет: манифест машинный.
- **`sync` — о наборе файлов, `pull`о содержимом.** `pull` берёт текст всех
подписок заново, и оставленный им дифф и есть смысл запуска. `sync` сверяет
манифест и подводит под него раскладку: чего не хватает — собирает, что
осиротело — удаляет. Копию с непустой локальной частью не удаляет никогда и
завершается с ошибкой, пока она лежит.
- **Позиционный аргумент отсекается явно.** `flag` прекращает разбор на первом
не-флаге, поэтому лишний аргумент не просто лежит без дела — он прячет все
флаги после себя. `noStrayArgs` в командах без позиционных, ручное снятие
темы с головы в `convy add`.
## Проверки
Семейства повторяют деление из `LANGUAGE.md`, раздел «Что стоит проверять
машиной», и это деление держится в коде: `form` — в любом файле, который язык
употребляет; `spread` — только в конвенциях, потому что эти проверки о том, что
документ уезжает к потребителю. Третья часть списка (взаимоисключительность
строк таблицы, покрытие области действия, отвечает ли обоснование на «что
сломается») разбором текста не даётся и в коде отсутствует намеренно.
Новая проверка заводится вместе с двумя тестами: что она срабатывает и что она
**молчит** там, где не должна. Второй важнее: проверка, краснеющая на исправном
файле, выключается целиком. Ложные срабатывания собраны в
`TestNoFalsePositives` для набора и в `TestCopiesAreSilentOnASoundCopy` для
копий.
Перед тем как заводить проверку, стоит прогнать её замысел по живому канону
(`dev-conventions`): если она покраснеет на исправном наборе, замысел неверен.
## Известные остатки
- Набор в поддиректории git-репозитория не адресуется: `#рев` есть, `//путь`
нет. Синтаксис, которого не просит ни один набор, не заводится заранее.
- Источник у проекта один. Модель нескольких допускает; форма `source = "..."`
расширяется до `[sources.имя]`, не ломая существующие манифесты.
- `convy add` пишет подписку после сборки. Если сборка прошла, а запись упала,
копия останется неучтённой — но это уже поправимо: `convy sync` её либо
уберёт, либо соберёт заново, когда тему подпишут.
- Ни линтеров, ни CI. Раздача — `go install`, пока этого хватает.
## Тесты
```
go test ./... всё
go test ./internal/check/ -v проверки, по одному подтесту на случай
gofmt -l . && go vet ./... перед коммитом
```
Тесты фикстурные: набор пишется во временную директорию и прогоняется целиком.
`internal/cli` проверяет обе моды, включая диалог — интерактивный режим иначе не
покрыть, из шелла он требует терминала. Проектные тесты строят набор теми же
командами и подключают его в проект: `subscribable` в `project_test.go`
`retirable` плюс `READING.md`, без которого копиям нечего везти рядом.
Git-транспорт проверяется на локальном репозитории через `file://` и
пропускается, если `git` не найден. Сети тесты не требуют.
## Коммиты
Русский, строчная буква, без точки в конце, прошедшее время или страдательный
залог. Изредка область через двоеточие (`suite check:`). Тело — маркированный
список на 2–4 пункта с переносом по ~76 колонок, объясняет почему. Conventional
Commits и `Co-Authored-By` не используются.
## Состояние
Наборная сторона: `init`, `add`, `rule`, `retire`, `list`, `check`.
Проектная: `init`, `add`, `pull`, `sync`, `list`, `check`. Обе стороны
закончены.
Отбор слоёв под компонент — один на обе стороны: `suite.Assemble`. `suite list`
показывает, что взял бы компонент, `convy pull` и `convy sync` то же самое
пишут в файл; разъехаться они не должны.
Линтеров и CI нет.
+318
View File
@@ -0,0 +1,318 @@
# convy
CLI для управления конвенциями разработки: ведёт набор конвенций и собирает
копии в проектах.
Модель, которую инструмент реализует, описана не здесь, а в репозитории набора
(`dev-conventions`): `README.md` — устройство набора и копий, `LANGUAGE.md`
форма правила, `GUIDE.md` — правила ведения набора. При расхождении истина там.
## Термины
| Уровень | Что это |
|---|---|
| язык | как записывается правило: слова, версия, `READING.md` |
| набор | репозиторий с конвенциями, манифестом `.conventions-suite.toml` и обвязкой |
| проект | репозиторий-потребитель с манифестом `.conventions.toml` |
| тема | набор правил об одном фокусе разработки; единица подписки |
| слой | один файл темы: базовый, языковой или стековый |
| компонент | адресат сборки в проекте: один язык, один стек, один вид приложения |
| префикс | четыре заглавные латинские буквы, адрес правила: `GTIM-3` |
Тему и ось слоя объявляет шапка файла, а не путь: `topic:`, `lang:`, `stack:`.
Слой без ключей оси — базовый, он попадает в копию всегда.
## Три уровня и ссылки между ними
Уровни стоят стопкой, и каждый нижний называет верхний ссылкой:
```
язык → слова, версия, короткое описание для читателя
набор → ссылается на язык: [language] version, lang, source
проект → ссылается на набор: source в .conventions.toml
```
Каждый уровень — набор файлов, а где эти файлы лежат, решает не модель, а
ссылка. Реализовано два транспорта:
| Ссылка | Что это |
|---|---|
| `../dev-conventions`, `/srv/conventions` | директория на диске, как она лежит |
| `https://git.example.org/av/conventions.git` | git-репозиторий, клонируется |
| `file:///srv/conventions#v2` | тот же репозиторий, но в закоммиченном виде |
Относительный путь считается от манифеста, который ссылку несёт. Хвост
`#ветка`, `#тег` или `#коммит` закрепляет ревизию и осмыслен только у git.
`ssh://` и `git@host:path` работают тем же клонированием, но проверены хуже.
Клон делается заново на каждый вызов и удаляется. Кэш сэкономил бы второй
клон и вернул бы вопрос, что в нём протухло, — а на «что было в прошлый раз»
отвечает git в репозитории-потребителе.
Ключ `[language] source` в наборе пока обычно пуст: описание языка живёт в
самом наборе. Когда спецификация уедет в свой репозиторий, тот же ключ её
назовёт, и больше ничего не изменится.
## Установка
Внешняя зависимость одна (`BurntSushi/toml`), сборка обычная:
```
go install git.vakhrushev.me/av/convy@latest
```
или из клона репозитория:
```
go build -o convy .
```
Готовых бинарей пока нет: `go install` хватает.
## Команды
```
В наборе:
convy suite init завести набор: директория и манифест
convy suite add завести конвенцию: файл, тема и префикс
convy suite rule дописать правило: следующий номер, блоки по порядку
convy suite retire снять правило, конвенцию или тему — без переиспользования
convy suite list что в наборе и что возьмёт компонент
convy suite check целостность набора: префиксы, темы, оси, ссылки, форма
В проекте:
convy init подключить конвенции: источник и первый компонент
convy add <тема> подписаться и собрать
convy pull пересобрать подписанное, текст и всё
convy sync привести файлы в соответствие манифесту
convy list что подключено и что ещё есть в наборе
convy check проверить форму того, что здесь
```
Контекст определяется по манифесту рядом: `.conventions-suite.toml` — набор,
`.conventions.toml` — проект. Наугад не делается ничего: команда не того уровня
отказывает и подсказывает нужную.
## Два режима
Команды, которые что-то меняют, работают в двух режимах, и режим читается по
командной строке.
**Без аргументов — диалог.** Каждое поле спрашивается с подсказкой о том, что
туда кладут и почему. Это для человека.
```
$ convy suite add
The focus the rules are about: time, config, db-schema. A topic is the unit of
subscription — a consumer takes it whole. The name never changes and is never reused.
Topic: logging
It goes into the manifest and builds the table of conventions in a consumer's README.
One line about the topic: логирование: уровни, структура записи
...
```
**С флагами — автоматика.** Всё передаётся сразу, вопросов не задаётся, все
недостающие поля называются разом. Это для агентов и скриптов.
```
$ convy suite add --topic logging --about "логирование: уровни" \
--prefix SLOG --title "Логирование"
```
Без терминала пустой вызов отказывает, а не виснет в ожидании ответа, которого
некому дать.
## Проверка набора
```
$ convy suite check
conventions/lang/go/logging.md
error:5 extends points at "arch/time.md" with topic "time", while the file
carries topic "logging": the layers of one topic declare one name [spread]
suite: 13 files, 8 topics, language version 1 (ru)
errors: 1, warnings: 0
```
Строка находки — `уровень:строка что не так [семейство]`. Семейств четыре:
- **manifest** — префиксы, темы, объявленные файлы, незарегистрированные файлы;
- **form** — форма правила: нумерация, блоки, модальные слова, словарь.
Проверяется в любом файле, который язык употребляет;
- **spread** — то, что относится к отъезду к потребителю: тема в шапке, ось,
`extends`, пути канона, самодостаточность нормы. Только в конвенциях;
- **links** — разрешение ссылок вида `PREFIX-N`.
Третья часть списка проверок языка — взаимоисключительность строк таблицы,
покрытие области действия, отвечает ли обоснование на «что сломается» — разбором
текста не даётся и остаётся работой читателя.
Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или
не в том контексте. Обе проверки принимают `--json` — те же находки в том же
порядке, для вызывающего, который не человек:
```json
{"findings":[{"severity":"error","family":"spread","path":"docs/conventions/time.md",
"line":22,"message":"..."}],"errors":1,"warnings":0}
```
Манифест набора называет ключом `governance` документ, которым набор ведёт
себя сам, — тот, что написан языком конвенций, но не принадлежит теме и потому
никуда не едет. Без этого ключа конвенция, потерявшая `topic`, была бы от него
неотличима.
## Подключение в проект
```
$ convy init --source ../dev-conventions --component backend \
--dir backend/docs/conventions --lang go --stack sqlite
$ convy add time
$ convy add logging --for backend
```
`init` проверяет источник до того, как писать манифест: набор, до которого
никто не доберётся, проходит любую проверку и никому не помогает. Получается
`.conventions.toml`:
```toml
source = "../dev-conventions"
[components.backend]
dir = "backend/docs/conventions"
lang = ["go"]
stack = ["sqlite"]
topics = ["logging", "time"]
```
Компонент пишется всегда, даже когда он один; при нескольких команда без
`--for` не угадывает, а перечисляет имена. `stack` — список: `sqlite` и
`postgres` действуют вместе, это разные таблицы одного сервиса. Два языка не
действуют вместе никогда — ради этого компонент и заведён.
## Манифесты — данные, а не текст
Оба манифеста инструмент и читает, и переписывает целиком. Поэтому комментариев
в них нет: файл, который машина переписывает, комментарий через круг не
проносит, а вид, что проносит, стоит этого комментария в день, когда никто не
смотрит. Объяснения живут в соседних файлах, которых ни одна команда не
касается: `convy suite init` заводит рядом `README.md` и пишет их туда.
Ключ, которого инструмент не знает, при записи потерялся бы. Поэтому он не
пишет вовсе:
```
$ convy suite add --topic time --about "время" --prefix TIME --title "Время"
.conventions-suite.toml holds 1 key the tool does not know
(language.descriptoin); a write goes out of what the tool understands, so the
key would be dropped — fix the spelling first
```
## Манифест — источник истины
`.conventions.toml` правится руками так же законно, как командой. Дальше
раскладку под него подводит `sync`:
```
$ convy sync --dry-run
backend → docs/conventions
+ docs/conventions/errors.md subscribed, and no file
- docs/conventions/logging.md nothing subscribes to "logging"
2 files would change; run without --dry-run to do it
```
Деление с `pull` проходит по тому, о чём команда. `pull`о содержимом:
берёт текст всех подписок заново, и оставленный им дифф и есть смысл запуска.
`sync` — о наборе файлов: чего манифест требует и нет — собирается, что есть и
никому не нужно — удаляется.
Копия с локальной частью не удаляется никогда: ниже маркера лежит
единственное, чего нет больше нигде. Такая копия называется в отчёте, и `sync`
завершается с ошибкой, пока её не убрали руками или не подписались снова.
Перед тем как что-то трогать, `sync` сверяет манифест: подписка на снятую или
несуществующую тему, тема дважды, два языка в одном компоненте, тема без
подходящего слоя, общая директория у двух компонентов. Находки называются
разом, и ничего не пишется.
Копия плоская, файл на тему. Первый слой — сам документ; каждый следующий
становится его разделом, и заголовки внутри опускаются на уровень: слой
реализует и сужает базу, а не стоит рядом с ней. Строка о версии языка
остаётся одна.
```markdown
---
origin: time
---
# Время
...
### TIME-1. Единый формат — RFC 3339, UTC
...
## Время: реализация на Go
...
#### GTIM-1. «Сейчас» берётся у слоя хранилища
...
<!-- conv:local -->
```
Всё ниже `<!-- conv:local -->` принадлежит репозиторию и переживает `pull`;
всё выше перезаписывается. Рядом с копиями кладётся `READING.md` — он
приезжает с уровня языка. `README.md` в той же директории принадлежит проекту
и не трогается, а файл, у которого убрали `origin:`, перестаёт быть копией:
`pull` его не перезапишет и скажет почему.
## Проверка проекта
`convy check` до набора не дотягивается и сети не требует: форма правила одна
и та же, локальные правила проекта на `X`-префиксах записаны по ней же.
Проверяются шапка `origin:`, маркер локальной части, нумерация по каждому
префиксу, блоки правила, словарь — и то, чего в наборе не бывает:
```
$ convy check
docs/conventions/time.md
error:22 rule XTIM-1 is a rule of the repository standing above the
marker: a reassembly would wipe it [spread]
project: 2 files in 1 component
errors: 1, warnings: 0
```
Язык копии определяется по строке о версии, которую она несёт: манифест рядом
с ней не лежит, и больше сказать некому.
## Ступени и словарь
Слова, которыми записаны модальность и метки, — свойство версии языка и
естественного языка набора, а не самого набора. Инструмент знает их сам, и
`.conventions-suite.toml` их не дублирует: хватает `[language] version` и
`lang`.
Поэтому ступень называется категорией, а не словом:
```
--modality requirement | prohibition | recommendation | not-recommended | permission
```
`prohibition` в русском наборе превращается в `**НЕ ДОЛЖЕН.**`, в английском —
в `**MUST NOT.**`. Вызывающему не нужно знать, на каком языке записан набор.
По той же причине `convy` умеет написать строку о версии языка, и созданный им
файл проходит `suite check` без единой правки.
## Чего инструмент не делает
- не сливает трёхсторонне и не разрешает конфликты: правка выше маркера
локальной части теряется, и это заявленное поведение;
- не ведёт лок-файл: копии закоммичены, ответ на «что было в прошлый раз» даёт
git;
- не хранит список подписчиков: подписка — свойство проекта;
- не переносит правки из проекта в набор: операция ручная и редкая;
- не перенумеровывает правила: номер — идентификатор, а не позиция;
- не кэширует источник: клон делается заново и удаляется;
- не знает нескольких наборов сразу: `source` в проекте один;
- не хранит комментарии в манифестах: они данные, а объяснения — в соседних
файлах.
+2
View File
@@ -1,3 +1,5 @@
module git.vakhrushev.me/av/convy module git.vakhrushev.me/av/convy
go 1.26.5 go 1.26.5
require github.com/BurntSushi/toml v1.6.0
+2
View File
@@ -0,0 +1,2 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+461
View File
@@ -0,0 +1,461 @@
package check_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/suite"
)
// The fixtures below stay in Russian on purpose: they are the data under test,
// written in the natural language the suite declares. Only the tool's own text
// is English.
// versionLine is the language version line. It lists the key words of the
// suite, which makes it the only place lawfully carrying modal words outside a
// rule.
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
const baseManifest = `
[language]
version = 1
description = "LANGUAGE.md"
reading = "READING.md"
[topics.live]
time = "время: хранение, зоны, форматы"
[topics.retired]
[prefixes.live]
TIME = "conventions/time.md"
[prefixes.retired]
`
const baseTime = `---
topic: time
prefix: TIME
---
# Время
Как приложение записывает моменты.
` + versionLine + `
## Правила
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
// reading stands for the short account of the language, the one document about
// it that travels into a copy. It has to name every word of the vocabulary:
// that is the whole of META-30.
const reading = `# Как читать конвенцию
Ключевые слова: ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ.
Метки: ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО, СНЯТО.
`
// files is the content of a suite: a path from the root mapped to the text of
// the file. An empty string means "no such file": that is how a test drops a
// file the base fixture provides.
type files map[string]string
func base() files {
return files{
".conventions-suite.toml": baseManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": reading,
"conventions/time.md": baseTime,
}
}
// run writes a suite into a temporary directory and runs the checks over it.
func run(t *testing.T, f files) []check.Finding {
t.Helper()
root := t.TempDir()
for name, content := range f {
if content == "" {
continue
}
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
s, err := suite.Load(root)
if err != nil {
t.Fatalf("loading the suite: %v", err)
}
return check.Suite(s).Findings()
}
func messages(findings []check.Finding) string {
var b strings.Builder
for _, f := range findings {
b.WriteString(f.Path)
b.WriteString(": ")
b.WriteString(f.Msg)
b.WriteString("\n")
}
return b.String()
}
func TestCleanSuite(t *testing.T) {
if got := run(t, base()); len(got) != 0 {
t.Fatalf("a sound suite produced findings:\n%s", messages(got))
}
}
// TestRuleHeadingIsNotAReference holds the line between a declaration and a
// reference. A heading with a foreign prefix is an error of form, and only
// that: there is nothing to resolve against the manifest, otherwise one typo
// would yield two findings about different things.
func TestRuleHeadingIsNotAReference(t *testing.T) {
f := base()
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
for _, got := range run(t, f) {
if got.Family == check.Links {
t.Errorf("a heading was parsed as a reference: %s", got.Msg)
}
}
}
// rule builds a whole rule so that tests do not repeat its form.
func rule(id, title, norm, rationale string) string {
return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n"
}
func TestChecks(t *testing.T) {
cases := []struct {
name string
setup func(files)
want string
}{{
name: "front matter prefix diverges from the manifest",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1)
},
want: "the front matter declares prefix GTIM, while the manifest assigns TIME to this file",
}, {
name: "rule heading carries a foreign prefix",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
},
want: "the rule heading uses prefix GTIM, while the file owns TIME",
}, {
name: "numbering has a gap",
setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.")
},
want: "numbering is not contiguous",
}, {
name: "a number is taken twice",
setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.")
},
want: "number TIME-1 is taken twice",
}, {
name: "rule without a rationale",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1)
},
want: "has no ПОЧЕМУ block: the rationale is mandatory",
}, {
name: "rule with neither a norm nor a stub",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1)
},
want: "has neither a norm block nor a СНЯТО stub",
}, {
name: "two norms under one number",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1)
},
want: "holds two norms (ДОЛЖЕН and СЛЕДУЕТ)",
}, {
name: "rationale precedes the norm",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1)
},
want: "the rationale precedes the norm",
}, {
name: "examples precede the rationale",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1)
},
want: "the ПРИМЕРЫ block precedes the rationale",
}, {
name: "stub of a retired rule without a date",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**СНЯТО.** Правило убрано за ненадобностью.", 1)
},
want: "carries no date of retirement",
}, {
name: "retired rule still holds a norm",
setup: func(f files) {
f["conventions/time.md"] = baseTime +
"\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n"
},
want: "still holds a norm block",
}, {
name: "no language version line",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1)
},
want: "holds no language version line",
}, {
name: "version line names a foreign version",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1)
},
want: "does not name version 1",
}, {
name: "modal word outside a rule area",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1)
},
want: "stands outside a rule area",
}, {
name: "word of a foreign vocabulary",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1)
},
want: `the word MUST belongs to the "en" vocabulary`,
}, {
name: "reference to a rule that does not exist",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри TIME-9. Без явного смещения", 1)
},
want: "reference TIME-9 does not resolve",
}, {
name: "reference to an unknown prefix",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1)
},
want: "prefix ZZZZ, which the suite manifest does not declare",
}, {
name: "topic not declared in the manifest",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1)
},
want: `topic "clocks" is not declared`,
}, {
name: "topic listed among the retired ones",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
"[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1)
},
want: "is listed both live and retired",
}, {
name: "live topic without layers",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`time = "время: хранение, зоны, форматы"`,
`time = "время"`+"\nlogging = \"логирование\"", 1)
},
want: `topic "logging" is declared live while the suite holds no layer of it`,
}, {
name: "declared file is missing",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1)
},
want: `prefix SLOG is assigned to the file "conventions/logging.md", which the suite does not hold`,
}, {
name: "file not registered in the manifest",
setup: func(f files) {
f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n"
},
want: "not declared in the suite manifest",
}, {
name: "prefix starts with X",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1)
f["conventions/time.md"] = strings.ReplaceAll(baseTime, "TIME", "XTIM")
},
want: "reserved for the local rules of consumers",
}, {
name: "one file with two prefixes declared",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1)
},
want: "has several prefixes declared for it",
}, {
name: "unknown manifest key",
setup: func(f files) {
f[".conventions-suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
},
want: "is unknown to the tool",
}, {
name: "mechanization mark in convention text",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1)
},
want: "its place is the note of mechanization in the local part of the copy",
}, {
name: "canon path in convention text",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
},
want: "the text holds the canon file path",
}, {
name: "document opens below level one",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "# Время", "## Время", 1)
},
want: "opens with a level-2 heading",
}, {
name: "second level-one heading",
setup: func(f files) {
f["conventions/time.md"] = baseTime + "\n# Ещё один заголовок\n\nПроза.\n"
},
want: "holds a second level-1 heading",
}, {
name: "heading skips a level",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила", "#### Правила", 1)
},
want: "skipping a level",
}, {
name: "scenario block opens with a foreign connective",
setup: func(f files) {
f["conventions/time.md"] = baseTime +
"\nWHEN зависимость недоступна\nТОГДА запись ERROR\n"
},
want: `the scenario block opens with WHEN from the "en" vocabulary`,
}, {
name: "topic name is not usable as a file name",
setup: func(f files) {
// A bare non-ASCII key TOML rejects on its own; a quoted one
// passes straight through, which is what the check is for.
f[".conventions-suite.toml"] = strings.Replace(baseManifest, "time =", `"время" =`, 1)
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: время", 1)
},
want: "is not usable as a file name",
}, {
name: "topic name is not lower kebab-case",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest, "time =", "Time_Zone =", 1)
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: Time_Zone", 1)
},
want: "is not lower kebab-case",
}, {
name: "a convention that lost its topic next to the governing document",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
},
want: "has lost the key",
}, {
name: "a convention that lost its topic in a suite with no governing document",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time\n", "", 1)
},
want: "the manifest names no document the suite governs itself by",
}, {
name: "the governing document declared with a topic",
setup: func(f files) {
f[".conventions-suite.toml"] = "governance = \"conventions/time.md\"\n" + baseManifest
},
want: "belongs to no topic",
}, {
name: "the short account of the language lost a word of the vocabulary",
setup: func(f files) {
f["READING.md"] = strings.Replace(reading, ", ДОПУСКАЕТСЯ", "", 1)
},
want: "does not name ДОПУСКАЕТСЯ",
}, {
name: "the governing document carries layer keys",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"conventions/go.md\"\n" + f[".conventions-suite.toml"]
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
},
want: "carries the keys of a layer",
}, {
name: "governance names a file that is not there",
setup: func(f files) {
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + baseManifest
},
want: "governance names",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := base()
tc.setup(f)
got := messages(run(t, f))
if !strings.Contains(got, tc.want) {
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
}
})
}
}
// The two names that mean something on the consumer's side as well: a
// convention naming either is talking about the copy, not about the suite.
func TestCanonPathLeavesTheConsumersOwnNames(t *testing.T) {
f := base()
f["README.md"] = "# Канон\n"
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
"Таблица тем собирается в README.md директории конвенций, а как читать\nправило — сказано в READING.md рядом с копиями.\n\n## Правила", 1)
for _, finding := range run(t, f) {
if strings.Contains(finding.Msg, "canon file path") {
t.Errorf("a name of the consumer's own was taken for a path of the suite: %s", finding.Msg)
}
}
// A path of a convention is still a path of a convention.
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
"Подробности — в conventions/time.md.\n\n## Правила", 1)
found := false
for _, finding := range run(t, f) {
if strings.Contains(finding.Msg, "canon file path") {
found = true
}
}
if !found {
t.Errorf("a path of a convention went unnoticed:\n%s", messages(run(t, f)))
}
}
+216
View File
@@ -0,0 +1,216 @@
package check
import (
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Checking a copy is not checking a suite with parts left out. A copy has no
// manifest next to it, no prefix of its own and no path back to where it came
// from — it declares its language by the version line and its origin by one
// key, and that is everything a consuming repository holds.
//
// So what is checked here is what a copy answers for on its own: the form of a
// rule, which is the same form the suite writes, and the two things only a copy
// has — the marker of the local part, and the rule that whatever is written
// below it takes a prefix on X.
// CopyMarker is the boundary between what the suite wrote and what the
// repository wrote. It is one constant, defined next to the parsing that has to
// respect it: two of them would drift apart in silence, and each half of the
// tool would then read a different file.
const CopyMarker = doc.LocalMarker
// Copy checks one assembled convention.
func Copy(d *doc.Document, rep *Report) {
if !checkOrigin(d, rep) {
return
}
v, ok := recognize(d, rep)
if !ok {
return
}
d.Blocks(v)
markers := d.Markers()
marker := 0
if len(markers) > 0 {
marker = markers[0]
}
checkMarker(d, markers, rep)
checkCopyHeadings(d, marker, rep)
checkHeadingHierarchy(d, rep)
for _, prefix := range prefixes(d) {
checkNumbering(d, prefix, rep)
}
checkRules(v, d, rep)
versionFrom, versionTo := checkVersionLine(v, d, rep)
checkModalsOutside(v, d, versionFrom, versionTo, rep)
checkForeignVocabulary(v, d, rep)
checkForeignConnectives(v, d, rep)
checkCopyLinks(d, rep)
}
// checkOrigin checks the front matter of a copy: one key, the name of the
// topic. The keys of a suite file have no business here — a copy is flat and
// carries no axis, and a stray extends would point at a path the repository
// does not have.
func checkOrigin(d *doc.Document, rep *Report) bool {
if !d.Front.Present || d.Front.Origin == "" {
rep.Errorf(Spread, d.Path, 1, "the file carries no origin key and is not a copy")
return false
}
for _, key := range []string{"topic", "prefix", "lang", "stack", "extends"} {
if at, ok := d.Front.At[key]; ok {
rep.Errorf(Spread, d.Path, at,
"the front matter of a copy carries the key %q of a suite file: a copy declares its topic by origin and nothing else", key)
}
}
for _, key := range d.Front.Unknown {
rep.Warnf(Spread, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
}
return true
}
// recognize works out which vocabulary the copy is written in.
func recognize(d *doc.Document, rep *Report) (lang.Vocabulary, bool) {
from, to := d.Preamble()
var text []string
for _, p := range d.Paragraphs(from, to) {
text = append(text, p.Text())
}
v, ok := lang.Recognize(strings.Join(text, "\n"))
if !ok {
start, _ := d.Preamble()
rep.Errorf(Form, d.Path, start,
"the introductory prose holds no language version line, and it is the only thing that says which words of this file are normative")
return lang.Vocabulary{}, false
}
return v, true
}
// checkMarker checks the boundary of the local part. A marker quoted inside a
// fenced block is not one — a convention about keeping copies carries such a
// quotation — and the parser has already left those out.
func checkMarker(d *doc.Document, markers []int, rep *Report) {
if len(markers) == 0 {
rep.Errorf(Spread, d.Path, d.Len(),
"the copy carries no %s marker: there is nowhere to write a derogation, and a reassembly would overwrite whatever was written instead", CopyMarker)
return
}
if len(markers) > 1 {
rep.Errorf(Spread, d.Path, markers[1],
"the copy carries a second %s marker: the marker is one, and everything below the first belongs to the repository", CopyMarker)
}
}
// checkCopyHeadings checks what a rule heading of a copy answers for. The level
// is not among it: layers below the first become sections of the document when
// assembled, and their rules step down with them.
func checkCopyHeadings(d *doc.Document, marker int, rep *Report) {
for _, r := range d.Rules {
if r.Malformed != "" {
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
}
local := marker > 0 && r.Line > marker
switch {
case local && !strings.HasPrefix(r.Prefix, "X"):
rep.Errorf(Spread, d.Path, r.Line,
"rule %s stands below the marker and takes a prefix the suite could hand out: a rule of the repository takes a prefix on X", r.ID())
case !local && strings.HasPrefix(r.Prefix, "X"):
rep.Errorf(Spread, d.Path, r.Line,
"rule %s is a rule of the repository standing above the marker: a reassembly would wipe it", r.ID())
}
}
}
// prefixes lists the prefixes the rules of a copy use, in a stable order. There
// is more than one: a copy gathers the layers of a topic, and a layer brings its
// own prefix along.
func prefixes(d *doc.Document) []string {
seen := make(map[string]bool)
var out []string
for _, r := range d.Rules {
if !seen[r.Prefix] {
seen[r.Prefix] = true
out = append(out, r.Prefix)
}
}
sort.Strings(out)
return out
}
// checkCopyLinks resolves the references a copy can resolve: the ones to a
// prefix the file itself holds. A reference to another topic is left alone — the
// repository may well not be subscribed to it, and that is legitimate (META-20).
func checkCopyLinks(d *doc.Document, rep *Report) {
own := make(map[string]map[int]bool)
for _, r := range d.Rules {
if own[r.Prefix] == nil {
own[r.Prefix] = make(map[int]bool)
}
own[r.Prefix][r.Num] = true
}
for _, ref := range refsIn(d, d.Body, d.Len()) {
nums, mine := own[ref.Prefix]
if !mine || nums[ref.Num] {
continue
}
rep.Errorf(Links, d.Path, ref.Line,
"reference %s points at a rule this file does not hold, while it does hold the rules of %s",
ref.Text, ref.Prefix)
}
}
// Copies checks every copy handed to it.
func Copies(docs []*doc.Document) *Report {
rep := &Report{}
for _, d := range docs {
Copy(d, rep)
}
return rep
}
// Dangling lists the references a copy makes to rules of topics the component
// did not take. Such a reference resolves nowhere for its reader: the rule it
// names exists, but not in this repository.
//
// It is not an error. META-20 allows a convention to name a rule of another
// topic outside the norm, and a rationale that lost its addressee degrades
// honestly — the reader loses a pointer rather than the requirement. So this is
// something to look at, and it lives here rather than in Copy because it needs
// the suite, which a check of copies deliberately does not reach.
func Dangling(d *doc.Document, s *suite.Suite, subscribed func(topic string) bool) []Ref {
own := make(map[string]bool)
for _, r := range d.Rules {
own[r.Prefix] = true
}
var out []Ref
seen := make(map[string]bool)
for _, ref := range refsIn(d, d.Body, d.Len()) {
if own[ref.Prefix] || strings.HasPrefix(ref.Prefix, "X") || seen[ref.Prefix] {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic == "" || subscribed(target.Front.Topic) {
continue
}
seen[ref.Prefix] = true
out = append(out, ref)
}
return out
}
// TopicOf names the topic a prefix belongs to.
func TopicOf(s *suite.Suite, prefix string) string {
if d, ok := s.ByPrefix[prefix]; ok {
return d.Front.Topic
}
return ""
}
+177
View File
@@ -0,0 +1,177 @@
package check_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/doc"
)
// A copy is checked without a suite next to it: no manifest, no prefix of its
// own, no path back to where it came from. These fixtures are therefore written
// out whole rather than assembled — what the check sees is what a consuming
// repository holds.
const copyVersionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
// soundCopy is what the assembler writes: two layers, the second a section of
// the first, a local part with a rule of the repository in it.
const soundCopy = `---
origin: time
---
# Время
Как приложение записывает моменты.
` + copyVersionLine + `
## Правила
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
### TIME-2. Ширина строки фиксируется
**СНЯТО 2026-07-27.** ширина следует из TIME-1.
## Время: реализация на Go
Как базовый слой выполняется в Go-коде.
### Правила
#### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из ` + "`store.Now()`" + `.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
<!-- conv:local -->
TIME-1 — МЕХАНИЗИРОВАНО: ` + "`internal/archrules`" + `.
Ссылка на чужую тему: SLOG-4 — эта копия её не держит.
### XTIM-1. Часы в тестах замораживаются
**ДОЛЖЕН.** Тест берёт время у подменённого ` + "`store.Now`" + `.
**ПОЧЕМУ.** Плавающее время делает падение теста невоспроизводимым.
`
func checkCopy(t *testing.T, body string) *check.Report {
t.Helper()
d, err := doc.Parse("docs/conventions/time.md", body)
if err != nil {
t.Fatalf("parsing the fixture: %v", err)
}
return check.Copies([]*doc.Document{d})
}
// The one that matters most: a check that reddens on a sound file is a check
// that gets switched off whole.
func TestCopiesAreSilentOnASoundCopy(t *testing.T) {
rep := checkCopy(t, soundCopy)
if len(rep.Findings()) == 0 {
return
}
var b strings.Builder
for _, f := range rep.Findings() {
b.WriteString(" " + f.Msg + "\n")
}
t.Errorf("a sound copy produced findings:\n%s", b.String())
}
// A convention about keeping copies quotes the marker in an example. The
// quotation is markup shown, not markup meant.
func TestCopiesReadNoMarkerInsideAFencedBlock(t *testing.T) {
quoting := strings.Replace(soundCopy, "## Время: реализация на Go",
"## Пример\n\n"+"```markdown\n<!-- conv:local -->\n```\n\n## Время: реализация на Go", 1)
rep := checkCopy(t, quoting)
for _, f := range rep.Findings() {
if strings.Contains(f.Msg, "marker") {
t.Errorf("a quoted marker was taken for the boundary: %s", f.Msg)
}
}
}
func TestCopiesCatchWhatOnlyACopyCanGetWrong(t *testing.T) {
cases := []struct {
name string
edit func(string) string
want string
}{{
name: "no origin key",
edit: func(s string) string { return strings.Replace(s, "origin: time", "topic: time", 1) },
want: "no origin key",
}, {
name: "a key of a suite file left in the front matter",
edit: func(s string) string {
return strings.Replace(s, "origin: time", "origin: time\nextends: arch/time.md", 1)
},
want: "key \"extends\" of a suite file",
}, {
name: "no marker at all",
edit: func(s string) string { return strings.Replace(s, "<!-- conv:local -->", "", 1) },
want: "carries no <!-- conv:local --> marker",
}, {
name: "a second marker",
edit: func(s string) string { return s + "\n<!-- conv:local -->\n" },
want: "second <!-- conv:local --> marker",
}, {
name: "a rule of the repository above the marker",
edit: func(s string) string {
return strings.Replace(s, "### TIME-2.", "### XTIM-9. Своё правило\n\n**ДОЛЖЕН.** Своя норма.\n\n**ПОЧЕМУ.** Своя причина.\n\n### TIME-2.", 1)
},
want: "would wipe it",
}, {
name: "a rule of the suite below the marker",
edit: func(s string) string { return strings.Replace(s, "### XTIM-1.", "### ZTIM-1.", 1) },
want: "takes a prefix the suite could hand out",
}, {
name: "a gap in the numbering of one of the prefixes",
edit: func(s string) string { return strings.Replace(s, "#### GTIM-1.", "#### GTIM-2.", 1) },
want: "numbering is not contiguous",
}, {
name: "a reference to a rule the file holds no such number of",
edit: func(s string) string {
return strings.Replace(s, "TIME-1 — МЕХАНИЗИРОВАНО", "TIME-9 — МЕХАНИЗИРОВАНО", 1)
},
want: "points at a rule this file does not hold",
}, {
name: "no language version line",
edit: func(s string) string {
return strings.Replace(s, copyVersionLine, "Просто вступление.", 1)
},
want: "no language version line",
}, {
name: "a word of another vocabulary",
edit: func(s string) string {
return strings.Replace(s, "**ДОЛЖЕН.** Момент", "**MUST.** Момент", 1)
},
want: "belongs to the \"en\" vocabulary",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := checkCopy(t, tc.edit(soundCopy))
for _, f := range rep.Findings() {
if strings.Contains(f.Msg, tc.want) {
return
}
}
var b strings.Builder
for _, f := range rep.Findings() {
b.WriteString(" " + f.Msg + "\n")
}
t.Errorf("nothing said %q; the findings were:\n%s", tc.want, b.String())
})
}
}
+405
View File
@@ -0,0 +1,405 @@
package check
import (
"regexp"
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkForm checks the form of a rule by parsing text. It applies to any file
// the language employs — both the conventions and the document the suite
// governs itself by.
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
prefix := checkFilePrefix(s, d, rep)
checkHeadings(d, prefix, rep)
checkHeadingHierarchy(d, rep)
checkNumbering(d, prefix, rep)
checkRules(s.Vocab, d, rep)
versionFrom, versionTo := checkVersionLine(s.Vocab, d, rep)
checkModalsOutside(s.Vocab, d, versionFrom, versionTo, rep)
checkForeignVocabulary(s.Vocab, d, rep)
checkForeignConnectives(s.Vocab, d, rep)
}
// checkHeadingHierarchy checks the ladder of headings: one title, and no level
// skipped on the way down. A rule area runs from a heading to the next heading
// of any level, so a skipped level does not merely look untidy — it moves the
// boundary the whole parsing model rests on.
func checkHeadingHierarchy(d *doc.Document, rep *Report) {
if len(d.Headings) == 0 {
return
}
if first := d.Headings[0]; first.Level != 1 {
rep.Errorf(Form, d.Path, first.Line,
"the document opens with a level-%d heading, while the title of a document is a level-1 heading", first.Level)
}
titles := 0
prev := 0
for _, h := range d.Headings {
if h.Level == 1 {
titles++
if titles > 1 {
rep.Errorf(Form, d.Path, h.Line,
"the document holds a second level-1 heading %q: the title is one, everything below it is a section", h.Text)
}
}
if prev > 0 && h.Level > prev+1 {
rep.Errorf(Form, d.Path, h.Line,
"the heading %q jumps from level %d to level %d, skipping a level", h.Text, prev, h.Level)
}
prev = h.Level
}
}
// checkForeignConnectives looks for scenario connectives of a foreign
// vocabulary. Only the start of a line counts: that is where a scenario block
// puts them, while mid-sentence AND and OR belong to SQL far more often than to
// a mixture of vocabularies.
func checkForeignConnectives(v lang.Vocabulary, d *doc.Document, rep *Report) {
foreign := lang.ForeignConnectives(v.Version, v.Code)
if len(foreign) == 0 {
return
}
words := make([]string, 0, len(foreign))
for w := range foreign {
words = append(words, w)
}
sort.Strings(words)
d.Prose(func(n int, text string) bool {
text = strings.TrimLeft(text, " \t>-*")
for _, w := range words {
if !strings.HasPrefix(text, w) || letterAt(text, len(w)) {
continue
}
rep.Errorf(Form, d.Path, n,
"the scenario block opens with %s from the %q vocabulary, while the document is written in %q",
w, foreign[w], v.Code)
break
}
return true
})
}
// checkFilePrefix reconciles the prefix in the front matter with the manifest
// and returns the prefix the file is supposed to use.
func checkFilePrefix(s *suite.Suite, d *doc.Document, rep *Report) string {
declared, _ := s.Manifest.PrefixOf(d.Path)
if !d.Front.Present {
rep.Errorf(Form, d.Path, 1, "the file has no front matter, while the manifest assigns prefix %s to it", declared)
return declared
}
if d.Front.Prefix == "" {
rep.Errorf(Form, d.Path, 1, "the front matter carries no prefix key")
return declared
}
at := d.Front.At["prefix"]
if d.Front.Prefix != declared {
rep.Errorf(Form, d.Path, at,
"the front matter declares prefix %s, while the manifest assigns %s to this file", d.Front.Prefix, declared)
}
if err := manifest.ValidPrefix(d.Front.Prefix); err != nil {
rep.Errorf(Form, d.Path, at, "%s", err)
}
if s.Manifest.PrefixRetired(d.Front.Prefix) {
rep.Errorf(Form, d.Path, at, "prefix %s is listed among the retired ones", d.Front.Prefix)
}
for _, key := range d.Front.Unknown {
rep.Warnf(Form, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
}
return declared
}
// checkHeadings checks the form of rule headings: the file's own prefix, the
// third level, a period after the identifier, a title.
func checkHeadings(d *doc.Document, prefix string, rep *Report) {
for _, r := range d.Rules {
if r.Prefix != prefix {
rep.Errorf(Form, d.Path, r.Line,
"the rule heading uses prefix %s, while the file owns %s", r.Prefix, prefix)
}
if r.HeadingLevel != 3 {
rep.Errorf(Form, d.Path, r.Line,
"the heading of rule %s sits at level %d, while a rule is a third-level heading", r.ID(), r.HeadingLevel)
}
if r.Malformed != "" {
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
}
}
}
// checkNumbering checks that numbering is contiguous: from one up to the
// highest, with no gaps and no repeats (META-31). A gap is indistinguishable
// from a typo in a number and from a rule someone forgot to finish — which is
// why there is never one, and a retired rule stays as a stub.
func checkNumbering(d *doc.Document, prefix string, rep *Report) {
seen := make(map[int][]int)
first := 0
for _, r := range d.Rules {
if r.Prefix != prefix {
continue
}
if first == 0 {
first = r.Line
}
seen[r.Num] = append(seen[r.Num], r.Line)
}
if len(seen) == 0 {
return
}
nums := make([]int, 0, len(seen))
highest := 0
for n := range seen {
nums = append(nums, n)
highest = max(highest, n)
}
sort.Ints(nums)
for _, n := range nums {
if lines := seen[n]; len(lines) > 1 {
rep.Errorf(Form, d.Path, lines[1],
"number %s is taken twice: lines %s", ruleID(prefix, n), joinInts(lines))
}
}
var gaps []int
for n := 1; n <= highest; n++ {
if _, ok := seen[n]; !ok {
gaps = append(gaps, n)
}
}
if len(gaps) > 0 {
rep.Errorf(Form, d.Path, first,
"numbering is not contiguous: the highest number is %d, missing %s — a retired rule stays as a stub instead of disappearing",
highest, joinInts(gaps))
}
}
var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
// checkRules checks what a rule is made of: either a norm with a rationale, or
// the stub of a retired one. Neither the norm nor the rationale is ever deleted
// (META-8, META-10).
func checkRules(v lang.Vocabulary, d *doc.Document, rep *Report) {
for _, r := range d.Rules {
if retired, ok := r.Block(lang.Retired); ok {
checkRetired(d, r, retired, rep)
continue
}
norms := r.Norms()
switch len(norms) {
case 0:
rep.Errorf(Form, d.Path, r.Line,
"rule %s has neither a norm block nor a %s stub", r.ID(), v.MarkWord(lang.Retired))
case 1:
if norms[0].Rest == "" {
rep.Errorf(Form, d.Path, norms[0].Start,
"in rule %s the %s mark opens no norm: nothing follows it", r.ID(), norms[0].Word)
}
default:
rep.Errorf(Form, d.Path, norms[1].Start,
"rule %s holds two norms (%s and %s): a norm is a single statement, otherwise a violation of one half of it has no address",
r.ID(), norms[0].Word, norms[1].Word)
}
rationale, ok := r.Block(lang.Rationale)
if !ok {
rep.Errorf(Form, d.Path, r.Line,
"rule %s has no %s block: the rationale is mandatory", r.ID(), v.MarkWord(lang.Rationale))
} else if len(norms) > 0 && rationale.Start < norms[0].Start {
rep.Errorf(Form, d.Path, rationale.Start,
"in rule %s the rationale precedes the norm: the order of blocks is norm, %s, %s",
r.ID(), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
}
if examples, ok := r.Block(lang.Examples); ok {
switch {
case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start:
rep.Errorf(Form, d.Path, examples.Start,
"in rule %s the %s block opens the rule: the order of blocks is norm, %s, %s",
r.ID(), v.MarkWord(lang.Examples), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
case ok && rationale.Start > examples.Start:
rep.Errorf(Form, d.Path, examples.Start,
"in rule %s the %s block precedes the rationale: the requirement first, then the reason, then the illustration",
r.ID(), v.MarkWord(lang.Examples))
}
}
}
}
// checkRetired checks the stub of a retired rule: a date and a reason.
func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
if len(r.Norms()) > 0 {
rep.Errorf(Form, d.Path, retired.Start,
"retired rule %s still holds a norm block: the stub replaces the norm together with the rationale", r.ID())
}
if !dateRe.MatchString(d.Line(retired.Start)) {
rep.Errorf(Form, d.Path, retired.Start,
"the stub of rule %s carries no date of retirement", r.ID())
}
if strings.TrimSpace(retired.Rest) == "" {
rep.Errorf(Form, d.Path, retired.Start,
"the stub of rule %s carries no reason for retirement", r.ID())
}
}
// checkVersionLine looks for the language version line in the introductory
// prose and returns the bounds of the paragraph carrying it.
//
// The line lists the key words of the suite and carries the rule of capitals
// itself — which makes it the only place outside rules where modal words are
// lawful.
func checkVersionLine(v lang.Vocabulary, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(v, d)
if !ok {
start, _ := d.Preamble()
rep.Errorf(Form, d.Path, start,
"the introductory prose holds no language version line: it lists the key words of the suite, and without it a convention in a foreign repository loses the key to its own text")
return 0, 0
}
version := strconv.Itoa(v.Version)
if !containsNumber(p.Text(), version) {
rep.Errorf(Form, d.Path, p.Start,
"the language version line does not name version %s, the version this document is read by", version)
}
return p.Start, p.End
}
// versionParagraph looks in the introductory prose for the paragraph carrying
// the language version line: the one listing every key word of the suite. It
// reports nothing — checkVersionLine speaks about its absence, and speaking
// twice helps no one.
func versionParagraph(v lang.Vocabulary, d *doc.Document) (doc.Paragraph, bool) {
from, to := d.Preamble()
words := v.Words()
for _, p := range d.Paragraphs(from, to) {
if containsAll(p.Text(), words) {
return p, true
}
}
return doc.Paragraph{}, false
}
// checkModalsOutside looks for capitalized modal words outside rule areas. An
// area runs from the heading of a rule to the next heading; everything else is
// prose, and prose is never a norm.
func checkModalsOutside(v lang.Vocabulary, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(v)
d.Prose(func(n int, text string) bool {
if d.InRule(n) || n >= versionFrom && n <= versionTo {
return true
}
for _, w := range words {
if !containsWord(text, w) {
continue
}
rep.Errorf(Form, d.Path, n,
"the modal word %s stands outside a rule area: capitalized spelling is normative, and prose cannot hold it", w)
break
}
return true
})
}
// checkForeignVocabulary looks for words of another vocabulary of the same
// language version. There is one vocabulary per suite: two ways of writing the
// same requirement double every check.
func checkForeignVocabulary(v lang.Vocabulary, d *doc.Document, rep *Report) {
foreign := lang.Foreign(v.Version, v.Code)
if len(foreign) == 0 {
return
}
words := make([]string, 0, len(foreign))
for w := range foreign {
words = append(words, w)
}
sort.Strings(words)
d.Prose(func(n int, text string) bool {
for _, w := range words {
if containsWord(text, w) {
rep.Errorf(Form, d.Path, n,
"the word %s belongs to the %q vocabulary, while the document is written in %q",
w, foreign[w], v.Code)
}
}
return true
})
}
func modalWords(v lang.Vocabulary) []string {
out := make([]string, 0, len(v.Modals))
for w := range v.Modals {
out = append(out, w)
}
sort.Strings(out)
return out
}
func containsAll(text string, words []string) bool {
for _, w := range words {
if !strings.Contains(text, w) {
return false
}
}
return true
}
// containsWord looks for a word as a whole: "MUSTARD" is not the word "MUST",
// while "MUST." is.
func containsWord(text, word string) bool {
for i := 0; ; {
j := strings.Index(text[i:], word)
if j < 0 {
return false
}
start := i + j
end := start + len(word)
if !letterBefore(text, start) && !letterAt(text, end) {
return true
}
i = start + len(word)
if i >= len(text) {
return false
}
}
}
func containsNumber(text, number string) bool {
for i := 0; ; {
j := strings.Index(text[i:], number)
if j < 0 {
return false
}
start := i + j
end := start + len(number)
if !digitBefore(text, start) && !digitAt(text, end) {
return true
}
i = end
if i >= len(text) {
return false
}
}
}
func ruleID(prefix string, num int) string {
return prefix + "-" + strconv.Itoa(num)
}
func joinInts(nums []int) string {
parts := make([]string, len(nums))
for i, n := range nums {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, ", ")
}
+293
View File
@@ -0,0 +1,293 @@
package check_test
import (
"strings"
"testing"
)
// A suite with two layers of one topic: a base architectural layer and a
// language layer on top of it. Everything about axes, extends and the boundary
// of a self-sufficient norm is checked here — a single layer cannot express it.
const layeredManifest = `
[language]
version = 1
description = "LANGUAGE.md"
reading = "READING.md"
[topics.live]
time = "время"
logging = "логирование"
[topics.retired]
[prefixes.live]
TIME = "conventions/arch/time.md"
GTIM = "conventions/lang/go/time.md"
SLOG = "conventions/arch/logging.md"
[prefixes.retired]
`
const archTime = `---
topic: time
prefix: TIME
---
# Время
Как приложение записывает моменты.
` + versionLine + `
## Правила
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
const goTime = `---
topic: time
prefix: GTIM
lang: go
extends: arch/time.md
---
# Время: реализация на Go
Как требования базового слоя выполняются в Go-коде.
` + versionLine + `
## Правила
### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из store.Now().
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
`
const archLogging = `---
topic: logging
prefix: SLOG
---
# Логирование
Как приложение пишет записи.
` + versionLine + `
## Правила
### SLOG-1. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
`
func layered() files {
return files{
".conventions-suite.toml": layeredManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": reading,
"conventions/arch/time.md": archTime,
"conventions/lang/go/time.md": goTime,
"conventions/arch/logging.md": archLogging,
}
}
func TestLayeredSuiteIsClean(t *testing.T) {
if got := run(t, layered()); len(got) != 0 {
t.Fatalf("a sound layered suite produced findings:\n%s", messages(got))
}
}
func TestLayeredChecks(t *testing.T) {
cases := []struct {
name string
setup func(files)
want string
}{{
name: "axis in the front matter diverges from the path",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
},
want: `the path puts the file on axis lang=go, while the front matter declares lang="python"`,
}, {
name: "extends leads into a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/logging.md", 1)
},
want: `with topic "logging", while the file carries topic "time"`,
}, {
name: "extends leads into a file that does not exist",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/clocks.md", 1)
},
want: `and the suite holds no such file`,
}, {
name: "topic with two layers lacking axis keys",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(layeredManifest,
`GTIM = "conventions/lang/go/time.md"`,
`GTIM = "conventions/second/time.md"`, 1)
f["conventions/lang/go/time.md"] = ""
f["conventions/second/time.md"] = strings.Replace(
strings.Replace(goTime, "lang: go\n", "", 1),
"extends: arch/time.md\n", "", 1)
},
want: "more than one layer without axis keys",
}, {
name: "norm references the prefix of a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
},
want: `refers to SLOG-1 from the foreign topic "logging"`,
}, {
name: "norm references a non-base layer of its own topic",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
},
want: "points at a layer of this topic that is not the base one",
}, {
name: "rationale of the base layer references a layer above it",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Без явного смещения не видно зоны; в Go это выражает GTIM-1.", 1)
},
want: "points at a layer of this topic that is not the base one",
}, {
name: "a section outside any rule references a layer above",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Связано\n\nРеализация на Go — GTIM-1.\n"
},
want: "points at a layer of this topic that is not the base one",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := layered()
tc.setup(f)
got := messages(run(t, f))
if !strings.Contains(got, tc.want) {
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
}
})
}
}
// TestNoFalsePositives gathers the cases where a check must stay silent. A
// false positive costs more here than a miss: a check that goes red on a sound
// file gets switched off altogether.
func TestNoFalsePositives(t *testing.T) {
cases := []struct {
name string
setup func(files)
}{{
name: "an identifier in backticks is a sample of notation, not a reference",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"Без явного смещения",
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
},
}, {
name: "a modal word inside a fenced code block",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
},
}, {
name: "an uppercase SQL keyword in a code sample",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1)
},
}, {
name: "a language layer's norm references the base layer of its own topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
},
}, {
name: "a mention of a step in the rationale is not a second norm",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
},
}, {
name: "a stub of a retired rule with a date and a reason",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Ширина следует из TIME-1 и отдельного правила не требует.\n"
},
}, {
name: "a scenario block in the suite's own vocabulary",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Стык правил\n\nКОГДА зависимость недоступна И ретраи исчерпаны\nТОГДА запись делается один раз (TIME-1)\n"
},
}, {
name: "an uppercase SQL connective mid-sentence outside a fence",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Условие `WHERE a AND b OR c` сортировку не спасает.", 1)
},
}, {
name: "a rationale references a foreign topic, which META-20 allows",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.",
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC; тот же довод стоит за SLOG-1.", 1)
},
}, {
name: "a section outside any rule references a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = goTime +
"\n## Связано\n\nКонвенция logging, правило SLOG-1.\n"
},
}, {
name: "a layer references the base layer of its topic outside a norm",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.",
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC, чего и требует TIME-1.", 1)
},
}, {
name: "the single document without a topic is the one the suite governs itself by",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(layeredManifest,
`SLOG = "conventions/arch/logging.md"`,
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
},
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := layered()
tc.setup(f)
if got := run(t, f); len(got) != 0 {
t.Fatalf("a check fired where it must not:\n%s", messages(got))
}
})
}
}
+121
View File
@@ -0,0 +1,121 @@
package check
import (
"regexp"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/suite"
)
// refRe catches a rule identifier: four uppercase Latin letters, a hyphen, a
// number and an optional table row number.
var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`)
// Ref is a reference to a rule found in the text.
type Ref struct {
Prefix string
Num int
Sub int
Line int
Text string
}
// refsIn collects the references in a range of lines. Inline code is cut out:
// inside backticks an identifier stands as a sample of the notation rather than
// as a reference to an assertion — otherwise the line "a rule is referred to by
// its identifier (`SLOG-27`)" would demand that rule SLOG-27 exist.
//
// Rule headings are skipped: a heading declares a rule instead of referring to
// one, and there is nothing to resolve against the manifest.
func refsIn(d *doc.Document, from, to int) []Ref {
heading := make(map[int]bool, len(d.Rules))
for _, r := range d.Rules {
heading[r.Line] = true
}
var out []Ref
for n := from; n <= to; n++ {
if d.Fenced(n) || heading[n] {
continue
}
out = append(out, refsInLine(n, doc.StripInline(d.Line(n)))...)
}
return out
}
func refsInLine(n int, text string) []Ref {
var out []Ref
for _, m := range refRe.FindAllStringSubmatch(text, -1) {
num, err := strconv.Atoi(m[2])
if err != nil {
continue
}
ref := Ref{Prefix: m[1], Num: num, Line: n, Text: m[0]}
if m[3] != "" {
ref.Sub, _ = strconv.Atoi(m[3])
}
out = append(out, ref)
}
return out
}
// checkLinks verifies that every reference resolves. An unresolved identifier
// is always an error: with stubs standing in for retired rules there is no
// third outcome — a reference leads either to a rule or to the explanation of
// why it was retired (META-31, META-32).
func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) {
for _, ref := range refsIn(d, d.Body, d.Len()) {
if strings.HasPrefix(ref.Prefix, "X") {
// A consumer's prefix: the local rules of a foreign repository
// are invisible to the suite and are not to be resolved.
continue
}
if s.Manifest.PrefixRetired(ref.Prefix) {
rep.Errorf(Links, d.Path, ref.Line,
"reference %s points at retired prefix %s", ref.Text, ref.Prefix)
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok {
if _, declared := s.Manifest.PathOf(ref.Prefix); declared {
// The file is declared but was not read — the manifest check
// has already said so, and saying it twice helps no one.
continue
}
rep.Errorf(Links, d.Path, ref.Line,
"reference %s points at prefix %s, which the suite manifest does not declare", ref.Text, ref.Prefix)
continue
}
rule, ok := ruleByNum(target, ref.Num)
if !ok {
rep.Errorf(Links, d.Path, ref.Line,
"reference %s does not resolve: %s holds no rule numbered %d", ref.Text, target.Path, ref.Num)
continue
}
if ref.Sub > 0 && !mentions(target, rule, ref.Text) {
rep.Errorf(Links, d.Path, ref.Line,
"reference %s does not resolve: the area of %s holds no such row", ref.Text, rule.ID())
}
}
}
// mentions reports whether the text of a reference occurs inside the area of a
// rule. That is how a table row number is checked: the row itself carries it.
func mentions(d *doc.Document, rule doc.Rule, text string) bool {
for n := rule.Line; n <= rule.End; n++ {
if strings.Contains(d.Line(n), text) {
return true
}
}
return false
}
func ruleByNum(d *doc.Document, num int) (doc.Rule, bool) {
for _, r := range d.Rules {
if r.Num == num {
return r, true
}
}
return doc.Rule{}, false
}
+138
View File
@@ -0,0 +1,138 @@
// Package check runs the integrity checks of a suite.
//
// The split into families is taken from the language and kept in the code: the
// form of a rule is checked in any file the language employs; spread only in
// convention files, because those checks are about a document travelling to a
// consumer. The third part of the list — rows of a table being mutually
// exclusive, the scope being covered, a norm being self-sufficient — is not
// here: it does not yield to parsing text and stays the reader's work.
package check
import (
"encoding/json"
"fmt"
"sort"
)
// Severity tells an error from a warning. An error is a violation named by a
// rule of the suite; a warning is something worth a look.
type Severity int
const (
Error Severity = iota + 1
Warning
)
func (s Severity) String() string {
if s == Warning {
return "warning"
}
return "error"
}
// Family is the family of checks a finding came from.
type Family string
const (
Manifest Family = "manifest"
Form Family = "form"
Spread Family = "spread"
Links Family = "links"
)
// Finding is a single finding.
type Finding struct {
Severity Severity
Family Family
// Path is the path of the file from the root of the suite; empty when the
// finding is about the suite as a whole.
Path string
// Line is the line of the file; zero when the finding is not bound to one.
Line int
Msg string
}
// Report accumulates the findings of one run.
type Report struct {
findings []Finding
}
// Errorf records an error.
func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) {
r.add(Error, f, path, line, format, args...)
}
// Warnf records a warning.
func (r *Report) Warnf(f Family, path string, line int, format string, args ...any) {
r.add(Warning, f, path, line, format, args...)
}
func (r *Report) add(s Severity, f Family, path string, line int, format string, args ...any) {
r.findings = append(r.findings, Finding{
Severity: s,
Family: f,
Path: path,
Line: line,
Msg: fmt.Sprintf(format, args...),
})
}
// Findings hands over the findings ordered by file and line.
func (r *Report) Findings() []Finding {
out := make([]Finding, len(r.findings))
copy(out, r.findings)
sort.SliceStable(out, func(i, j int) bool {
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Line < out[j].Line
})
return out
}
// Errors counts the findings of error severity.
func (r *Report) Errors() int {
n := 0
for _, f := range r.findings {
if f.Severity == Error {
n++
}
}
return n
}
// Warnings counts the warnings.
func (r *Report) Warnings() int {
return len(r.findings) - r.Errors()
}
// MarshalJSON writes a finding the way a machine reads it: the severity and the
// family as words rather than as the numbers they happen to be inside.
func (f Finding) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Severity string `json:"severity"`
Family string `json:"family"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
Message string `json:"message"`
}{f.Severity.String(), string(f.Family), f.Path, f.Line, f.Msg})
}
// JSON renders the report for a caller that is not a person. Findings come out
// in the order they are printed in, so the two outputs never disagree about
// what was found first.
func (r *Report) JSON() ([]byte, error) {
out := struct {
Findings []Finding `json:"findings"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}{r.Findings(), r.Errors(), r.Warnings()}
if out.Findings == nil {
out.Findings = []Finding{}
}
body, err := json.Marshal(out)
if err != nil {
return nil, err
}
return append(body, '\n'), nil
}
+263
View File
@@ -0,0 +1,263 @@
package check
import (
"path"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/project"
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkSpread checks what bears on a document travelling to a consumer. It
// applies to convention files only: the document the suite governs itself by
// travels nowhere, and a canon path inside it is lawful.
func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkTopic(s, d, rep)
checkAxis(d, rep)
checkExtends(s, d, rep)
checkMechanized(s, d, rep)
checkCanonPaths(s, d, rep)
checkForeignTopicInNorm(s, d, rep)
checkOwnTopicLayerRefs(s, d, rep)
}
// checkTopic reconciles the topic from the front matter with the manifest
// (META-28, META-29).
func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) {
topic := d.Front.Topic
at := d.Front.At["topic"]
switch {
case s.Manifest.TopicRetired(topic):
rep.Errorf(Spread, d.Path, at,
"topic %q is listed among the retired ones: a retired name is never handed to another topic", topic)
case !s.Manifest.TopicLive(topic):
rep.Errorf(Spread, d.Path, at,
"topic %q is not declared in the suite manifest", topic)
}
}
// checkAxis reconciles the declared axis with the path of the file. An axis is
// declared in the front matter rather than derived from the path (META-38); but
// once axis directories are in use, a divergence means the file moved and the
// front matter did not.
func checkAxis(d *doc.Document, rep *Report) {
parts := strings.Split(path.Dir(d.Path), "/")
for i := 0; i+1 < len(parts); i++ {
var declared, key string
switch parts[i] {
case "lang":
declared, key = d.Front.Lang, "lang"
case "stack":
declared, key = d.Front.Stack, "stack"
default:
continue
}
if declared == parts[i+1] {
continue
}
at := d.Front.At[key]
if at == 0 {
at = d.Front.At["prefix"]
}
rep.Errorf(Spread, d.Path, at,
"the path puts the file on axis %s=%s, while the front matter declares %s=%q", key, parts[i+1], key, declared)
}
}
// checkExtends verifies that the declared base exists and belongs to the same
// topic. The key documents the tie between layers for a human — and
// documentation that lies is worse than none.
func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
if d.Front.Extends == "" {
return
}
at := d.Front.At["extends"]
target, others := resolveExtends(s, d, d.Front.Extends)
if len(others) > 1 {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds several files it could mean (%v): give the path from the root of the suite",
d.Front.Extends, others)
return
}
if target == nil {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds no such file", d.Front.Extends)
return
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, at,
"extends points at %q with topic %q, while the file carries topic %q: the layers of one topic declare one name",
d.Front.Extends, target.Front.Topic, d.Front.Topic)
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, which is not a base layer: it declares an axis", d.Front.Extends)
}
}
// resolveExtends looks up the document at the path written in extends.
//
// The path may be given from the root of the suite or from the directory of
// conventions, so an exact match is tried first and a tail match second. A tail
// match can fit several files at once — two layers of one topic often share a
// file name — so every candidate is returned and the caller reports the
// ambiguity instead of picking by the order documents happen to be loaded in.
// The document doing the extending is never its own base.
func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Document, []string) {
ref = path.Clean(strings.TrimPrefix(ref, "./"))
var candidates []*doc.Document
for _, d := range s.Docs {
if d == from {
continue
}
if d.Path == ref {
return d, []string{d.Path}
}
if strings.HasSuffix(d.Path, "/"+ref) {
candidates = append(candidates, d)
}
}
paths := make([]string, len(candidates))
for i, d := range candidates {
paths[i] = d.Path
}
sort.Strings(paths)
if len(candidates) == 1 {
return candidates[0], paths
}
return nil, paths
}
// namesSuiteFile reports whether a candidate written in the text names a file
// the suite holds. Unlike an extends key it needs no single answer: a path that
// fits several files of the canon is a path all the same.
func namesSuiteFile(s *suite.Suite, candidate string) bool {
// Two names mean something on the consumer's side as well, and a
// convention naming either of them is talking about the copy rather than
// about the suite: README.md belongs to the consuming repository, and
// READING.md is the guide that travels next to the copies.
switch path.Base(candidate) {
case "README.md", project.ReadingName:
return false
}
if s.Exists(candidate) {
return true
}
for _, d := range s.Docs {
if d.Path == candidate || strings.HasSuffix(d.Path, "/"+candidate) {
return true
}
}
return false
}
// checkMechanized looks for the mark of mechanization in the text of a
// convention. Whether a norm is mechanized is a property of a repository rather
// than of the suite, so the place of the mark is the local part of the copy
// (META-7).
func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
word := s.Vocab.MarkWord(lang.Mechanized)
if word == "" {
return
}
version, hasVersion := versionParagraph(s.Vocab, d)
d.Prose(func(n int, text string) bool {
if hasVersion && n >= version.Start && n <= version.End {
return true
}
if containsWord(text, word) {
rep.Errorf(Spread, d.Path, n,
"the %s mark stands in the text of a convention: its place is the note of mechanization in the local part of the copy", word)
}
return true
})
}
// mdPathRe catches what looks like a path to a file of the suite.
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
// checkCanonPaths looks for the path of a canon file in the text of a
// convention (META-21). In a consumer's repository a convention lies assembled,
// the layers of one topic are sections of one file, and the path
// `lang/go/logging.md` does not exist there: a reference to it dies on assembly,
// and dies in silence — the text stays coherent.
//
// Inline code is not cut out here: a path in backticks is still a path.
func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) {
if !namesSuiteFile(s, candidate) {
continue
}
rep.Errorf(Spread, d.Path, n,
"the text holds the canon file path %q: refer by the name of a topic or the identifier of a rule", candidate)
}
}
}
// checkForeignTopicInNorm looks for the prefix of a foreign topic inside a norm
// block (META-20). The norm of a rule must be executable holding this one file:
// a repository subscribes to an arbitrary subset of the conventions, and it has
// no dependency graph by construction.
//
// Outside a norm such a reference is lawful and stays unchecked. A rationale
// that loses its addressee degrades honestly — the cross-check goes, the
// meaning stays — while a norm missing a neighbouring file becomes unenforceable
// in silence.
func checkForeignTopicInNorm(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d)
for _, r := range d.Rules {
for _, norm := range r.Norms() {
for _, ref := range refsIn(d, norm.Start, norm.End) {
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic == d.Front.Topic {
continue
}
rep.Errorf(Spread, d.Path, ref.Line,
"the norm of %s refers to %s from the foreign topic %q: only the rationale looks outward",
r.ID(), ref.Text, target.Front.Topic)
}
}
}
}
// checkOwnTopicLayerRefs holds the direction of references inside one topic:
// only the base layer may be pointed at, and from anywhere in the document
// rather than from the norm alone.
//
// Guaranteed to stand in the copy is exactly one layer of a topic — the base
// one; it goes in whatever language and stack were chosen (META-24). The order
// of assembly, base then language then stack, is the order of concatenation and
// not a chain of dependency: a consumer is free to take stack=htmx with
// lang=python, so a stack layer has no claim on a language layer either.
//
// The reason reaches past dangling links. The base layer is the text that has
// to hold for every language and every stack; a rationale of its that needs a
// rule of the Go layer to explain itself is the specific leaking into the
// shared, and the reasoning belongs in that layer instead. The base layer is
// therefore left no way to point at a layer above it at all — by design.
func checkOwnTopicLayerRefs(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d)
for _, ref := range refsIn(d, d.Body, d.Len()) {
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic != d.Front.Topic || !target.Front.Axis() {
continue
}
rep.Errorf(Spread, d.Path, ref.Line,
"the reference %s points at a layer of this topic that is not the base one: only the base layer is guaranteed to stand in a copy, and a rule that needs this one belongs in that layer",
ref.Text)
}
}
+257
View File
@@ -0,0 +1,257 @@
package check
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Suite runs every check of a suite and returns the report.
func Suite(s *suite.Suite) *Report {
rep := &Report{}
checkManifest(s, rep)
checkLanguageSource(s, rep)
checkTopicNames(s, rep)
checkBaseLayers(s, rep)
checkSelfGoverning(s, rep)
checkReadingVocabulary(s, rep)
for _, d := range s.Docs {
checkForm(s, d, rep)
checkLinks(s, d, rep)
if d.Front.Topic != "" {
checkSpread(s, d, rep)
}
}
return rep
}
// checkManifest checks the manifest itself: the shape of prefixes, live and
// retired not overlapping, the declared files being present and no undeclared
// ones lying around.
func checkManifest(s *suite.Suite, rep *Report) {
m := s.Manifest
for _, key := range m.Undecoded {
rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key)
}
// The documents about the language are named relative to the level they
// belong to. While that level is the suite, the files have to be here; once
// the language lives apart, they are out of reach of a check that runs on
// every edit and must not touch the network.
if m.Language.Source == "" {
for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) {
rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name)
}
}
}
byPath := make(map[string][]string)
for _, prefix := range m.LivePrefixes() {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
}
if m.PrefixRetired(prefix) {
rep.Errorf(Manifest, manifest.Name, 0,
"prefix %s is listed both live and retired: a retired one is never reissued", prefix)
}
path := m.Prefixes.Live[prefix]
byPath[path] = append(byPath[path], prefix)
}
for _, path := range sortedKeys(byPath) {
if prefixes := byPath[path]; len(prefixes) > 1 {
sort.Strings(prefixes)
rep.Errorf(Manifest, manifest.Name, 0,
"the file %q has several prefixes declared for it (%v): a prefix belongs to one file", path, prefixes)
}
}
for prefix := range m.Prefixes.Retired {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "among the retired ones: %s", err)
}
}
for _, prefix := range sortedKeys(s.Missing) {
rep.Errorf(Manifest, manifest.Name, 0,
"prefix %s is assigned to the file %q, which the suite does not hold", prefix, s.Missing[prefix])
}
for _, path := range s.Unregistered {
rep.Errorf(Manifest, path, 1,
"the file is written in the conventions language yet not declared in the suite manifest: for the suite it does not exist")
}
for _, err := range s.Broken {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
}
for _, topic := range m.LiveTopics() {
if m.TopicRetired(topic) {
rep.Errorf(Manifest, manifest.Name, 0,
"topic %q is listed both live and retired", topic)
}
if len(s.Layers(topic)) == 0 {
rep.Errorf(Manifest, manifest.Name, 0,
"topic %q is declared live while the suite holds no layer of it: a topic lives as long as at least one layer does", topic)
}
}
}
var (
// topicNameRe is the hard bound: a topic name reaches the file system of
// a consumer, so it has to be usable as a file name — Latin letters,
// digits and the plain separators.
topicNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// kebabRe is the recommended shape, and only a recommendation.
kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
)
// checkTopicNames checks the shape of topic names. A name lands both in a
// consumer's file system and in their manifest, which is where the hard bound
// comes from; lower kebab-case on top of that is a recommendation and speaks as
// a warning.
func checkTopicNames(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() {
switch {
case !topicNameRe.MatchString(topic):
rep.Errorf(Manifest, manifest.Name, 0,
"the topic name %q is not usable as a file name: a name is written in Latin letters and travels into the file system of a consumer", topic)
case !kebabRe.MatchString(topic):
rep.Warnf(Manifest, manifest.Name, 0,
"the topic name %q is not lower kebab-case, which is the recommended shape", topic)
}
}
}
// checkSelfGoverning guards the document without a topic.
//
// A topic-less document is the one the suite governs itself by: it travels
// nowhere and cannot be subscribed to, so it gets no spread checks. A
// convention that lost its topic key looks exactly the same, and that loss is
// the expensive one — the file keeps every check of form while quietly dropping
// every check about travelling to a consumer.
//
// Which file it is, the manifest says. Guessing was tried and does not reach:
// "there is one of them" and "it has no axis keys" both hold for a suite whose
// only topic-less file is a convention with the key knocked out.
func checkSelfGoverning(s *suite.Suite, rep *Report) {
declared := s.Manifest.Governance
if declared != "" && !s.Exists(declared) {
rep.Errorf(Manifest, manifest.Name, 0,
"governance names %q, and the file is missing", declared)
}
for _, d := range s.Docs {
if d.Front.Topic != "" {
if s.Manifest.Governs(d.Path) {
rep.Errorf(Manifest, d.Path, d.Front.At["topic"],
"the manifest names this file the one the suite governs itself by, and it declares topic %q: such a document belongs to no topic, because nobody may subscribe to it", d.Front.Topic)
}
continue
}
switch {
case declared == "":
rep.Errorf(Manifest, d.Path, 1,
"the file declares no topic, and the manifest names no document the suite governs itself by: either the topic key is lost, or the manifest has to say governance = %q", d.Path)
case !s.Manifest.Governs(d.Path):
rep.Errorf(Manifest, d.Path, 1,
"the file declares no topic, while the manifest names %q as the one the suite governs itself by: a convention without a topic has lost the key", declared)
case d.Front.Axis() || d.Front.Extends != "":
rep.Errorf(Manifest, d.Path, d.Front.At["prefix"],
"the document the suite governs itself by carries the keys of a layer: it is nobody's layer, having no topic to be a layer of")
}
}
}
// checkReadingVocabulary checks that the short account of the language names
// every word of the vocabulary (META-30).
//
// The full account stays with the author of the suite, while the rules are
// applied by the reader of a copy — a person or an agent in a foreign
// repository who holds the short one and nothing else. Let the two drift apart
// and that reader starts reading the words by an older version: ДОПУСКАЕТСЯ
// turns back into an everyday "you may", a departure from ДОЛЖЕН stops
// demanding a record. Exactly what the words were introduced for fails, and it
// fails in silence.
func checkReadingVocabulary(s *suite.Suite, rep *Report) {
name := s.Manifest.Language.Reading
if s.Manifest.Language.Source != "" {
// The account lies at a level of its own, and reaching it costs a
// fetch. Checking the integrity of a suite runs on every edit, so it
// stays local; the reference itself is checked instead.
return
}
if name == "" {
// A suite that has not written the document yet; `suite init` says so
// among the next steps, and repeating it on every run is noise.
return
}
body, err := os.ReadFile(filepath.Join(s.Root, filepath.FromSlash(name)))
if err != nil {
// The missing file is reported by the manifest check already.
return
}
var missing []string
for _, word := range s.Vocab.Words() {
if !containsWord(string(body), word) {
missing = append(missing, word)
}
}
if len(missing) > 0 {
rep.Errorf(Form, name, 0,
"the short account of the language does not name %s: it is the only key to the text of a rule a reader of a copy holds, and a word missing from it is a word read by whatever version the reader remembers",
strings.Join(missing, ", "))
}
}
// checkBaseLayers checks that a topic holds no more than one layer without axis
// keys. The base layer is the only one of its kind: it reaches every copy, and a
// second such layer would mean two base texts in one assembled file.
func checkBaseLayers(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() {
var base []string
for _, d := range s.Layers(topic) {
if !d.Front.Axis() {
base = append(base, d.Path)
}
}
if len(base) > 1 {
sort.Strings(base)
for _, path := range base[1:] {
rep.Errorf(Spread, path, 1,
"topic %q holds more than one layer without axis keys: the base layer is the only one of its kind, and the candidates are %v",
topic, base)
}
}
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// checkLanguageSource checks the reference to the level of the language. What
// it names cannot be reached without a fetch, and a check of a suite does not
// fetch — but a reference that means nothing is caught here rather than at the
// first assembly in a foreign repository.
func checkLanguageSource(s *suite.Suite, rep *Report) {
raw := s.Manifest.Language.Source
if raw == "" {
return
}
if _, err := source.Parse(raw); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "[language] source: %s", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package check
import (
"unicode"
"unicode/utf8"
)
// letterAt reports whether offset i holds a letter or a digit. Cyrillic takes
// two bytes in UTF-8, so a single byte is not enough to decide.
func letterAt(text string, i int) bool {
if i >= len(text) {
return false
}
r, _ := utf8.DecodeRuneInString(text[i:])
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// letterBefore reports whether a letter or a digit stands before offset i.
func letterBefore(text string, i int) bool {
if i <= 0 {
return false
}
r, _ := utf8.DecodeLastRuneInString(text[:i])
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
func digitAt(text string, i int) bool {
if i >= len(text) {
return false
}
r, _ := utf8.DecodeRuneInString(text[i:])
return unicode.IsDigit(r)
}
func digitBefore(text string, i int) bool {
if i <= 0 {
return false
}
r, _ := utf8.DecodeLastRuneInString(text[:i])
return unicode.IsDigit(r)
}
+154
View File
@@ -0,0 +1,154 @@
package cli
import (
"flag"
"fmt"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy add does two things at once, and they are one thing: it writes the
// subscription into the manifest and assembles the file. A subscription that
// left no file behind, or a file nothing subscribed to, is the state the model
// has no name for.
func runAdd(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy add", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to assemble for; needed when there is more than one")
topicFlag := fs.String("topic", "", "topic to subscribe to; may also be given as the first argument")
// The topic is taken off the front before the flags are parsed. The flag
// package stops at the first argument that is not a flag, so "convy add
// time --for backend" — the form the model writes — would otherwise lose
// the component silently.
topic := ""
rest := args
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
topic, rest = rest[0], rest[1:]
}
if err := fs.Parse(rest); err != nil {
return Usage
}
if *topicFlag != "" {
if topic != "" && topic != *topicFlag {
fmt.Fprintf(env.Err, "the topic is named twice and differently: %q and %q\n", topic, *topicFlag)
return Usage
}
topic = *topicFlag
}
if left := fs.Args(); len(left) > 0 {
fmt.Fprintf(env.Err, "convy add takes one topic, and %q came after it as well\n", left[0])
return Usage
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
if err := distinctDirs(o.Manifest); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
name, c, code := componentOf(env, o.Manifest, *forComponent)
if code != OK {
return code
}
if topic == "" {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy add without arguments asks which topic, and there is no terminal to ask on; name the topic as an argument")
return Usage
}
given := map[string]string{}
if err := askAll(env, []Field{topicField(o, c)}, given); err != nil {
return Usage
}
topic = given["topic"]
}
if !o.Suite.Manifest.TopicLive(topic) {
if o.Suite.Manifest.TopicRetired(topic) {
fmt.Fprintf(env.Err, "the suite has retired the topic %q: %s\n", topic, o.Suite.Manifest.Topics.Retired[topic])
return Usage
}
fmt.Fprintf(env.Err, "the suite declares no topic %q; it declares: %s\n", topic, strings.Join(o.Suite.Manifest.LiveTopics(), ", "))
return Usage
}
if c.Subscribed(topic) {
fmt.Fprintf(env.Err, "the component %q is subscribed to %q already; convy pull reassembles it\n", name, topic)
return Usage
}
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
// The manifest is written after the file: a subscription recorded against
// an assembly that failed would send the next pull looking for a copy that
// was never made.
o.Manifest.Subscribe(name, topic)
if err := o.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
if err != nil {
fmt.Fprintf(env.Err, "warning: %s\n", err)
}
fmt.Fprintf(env.Out, "\n%s → %s\n", topic, made.Path)
for _, layer := range made.Layers {
fmt.Fprintf(env.Out, " %s\n", layer)
}
if guide != "" {
fmt.Fprintf(env.Out, "\n%s refreshed\n", guide)
}
fmt.Fprintf(env.Out, "subscribed the component %q in %s\n", name, manifest.ProjectName)
return OK
}
// topicField offers the topics the component has not taken yet.
func topicField(o *opened, c manifest.Component) Field {
var free []string
for _, t := range o.Suite.Manifest.LiveTopics() {
if !c.Subscribed(t) {
free = append(free, t)
}
}
return Field{
Flag: "topic",
Ask: "Topic",
Hint: "A topic is taken whole: the file gathers every layer of it the component fits. " + describeTopics(o, free),
Options: free,
Check: func(v string) error {
if !o.Suite.Manifest.TopicLive(v) {
return fmt.Errorf("the suite declares no topic %q", v)
}
if c.Subscribed(v) {
return fmt.Errorf("this component is subscribed to %q already", v)
}
return nil
},
}
}
func describeTopics(o *opened, free []string) string {
if len(free) == 0 {
return "The component is subscribed to everything the suite has."
}
parts := make([]string, 0, len(free))
for _, t := range free {
parts = append(parts, fmt.Sprintf("%s — %s", t, o.Suite.Manifest.Topics.Live[t]))
}
return "On offer: " + strings.Join(parts, "; ") + "."
}
+162
View File
@@ -0,0 +1,162 @@
package cli
import (
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy check stays at the top level and reaches for no suite. The form of a
// rule is one and the same, the local rules of the repository on X prefixes are
// written by that same form, and checking what lies here has to work without a
// network and without knowing where the copies came from.
func runCheck(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy check", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to check; every one of them by default")
quiet := fs.Bool("quiet", false, "print findings only")
asJSON := fs.Bool("json", false, "write the findings as JSON, for a caller that is not a person")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy check", fs.Args()); code != OK {
return code
}
dir, code := projectRoot(env, *root)
if code != OK {
return code
}
m, code := loadProject(env, dir)
if code != OK {
return code
}
if err := distinctDirs(m); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
names, code := components(env, m, *forComponent)
if code != OK {
return code
}
var docs []*doc.Document
var broken []error
for _, name := range names {
c := m.Components[name]
if c.Dir == "" {
fmt.Fprintf(env.Err, "the component %q names no dir, and there is nothing to look in\n", name)
return Usage
}
found, errs := copies(dir, c.Dir)
docs = append(docs, found...)
broken = append(broken, errs...)
}
docs = distinct(docs)
rep := check.Copies(docs)
for _, err := range broken {
fmt.Fprintf(env.Err, "%s\n", err)
}
if *asJSON {
if code := printJSON(env, rep); code != OK {
return code
}
} else {
printCopyReport(env.Out, rep, len(docs), len(names), *quiet)
}
if rep.Errors() > 0 || len(broken) > 0 {
return Failed
}
return OK
}
// copies collects the assembled conventions of one component directory.
//
// What is a copy is decided by the origin key rather than by the name of the
// file: README.md belongs to the repository, READING.md belongs to the suite,
// and a file whose origin key was taken away has become a document of the
// repository — none of the three answers to the form of a rule.
func copies(root, dir string) ([]*doc.Document, []error) {
var docs []*doc.Document
var broken []error
base := filepath.Join(root, filepath.FromSlash(dir))
err := filepath.WalkDir(base, func(name string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
return nil
}
rel, err := filepath.Rel(root, name)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
d, err := doc.Load(rel, name)
if err != nil {
broken = append(broken, err)
return nil
}
if d.Front.Origin == "" {
return nil
}
docs = append(docs, d)
return nil
})
if err != nil && !os.IsNotExist(err) {
broken = append(broken, fmt.Errorf("walking %s: %w", dir, err))
}
sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path })
return docs, broken
}
// distinct drops a document reached through two components. Directories are
// checked for equality before this, but one may still lie inside another, and a
// finding printed twice reads as two.
func distinct(docs []*doc.Document) []*doc.Document {
seen := make(map[string]bool, len(docs))
out := docs[:0]
for _, d := range docs {
if seen[d.Path] {
continue
}
seen[d.Path] = true
out = append(out, d)
}
return out
}
func printCopyReport(w io.Writer, rep *check.Report, files, comps int, quiet bool) {
findings := rep.Findings()
printFindings(w, findings)
if quiet {
return
}
if len(findings) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "project: %s in %s\n", plural(files, "file"), plural(comps, "component"))
switch {
case rep.Errors() > 0:
fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings())
case rep.Warnings() > 0:
fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
case files == 0:
fmt.Fprintf(w, "nothing to check: no file carries an origin key; convy pull assembles the copies\n")
default:
fmt.Fprintf(w, "the copies hold the form; everything below %s is the repository's own\n", project.LocalMarker)
}
}
+175
View File
@@ -0,0 +1,175 @@
// Package cli lays out the commands of the tool.
//
// The depth of a command reflects how often it runs and whom it addresses:
// project commands run in every repository and often, tending a suite runs in
// one repository and rarely. That is why `check` stays at the top level and
// whatever makes no sense in a project goes under `suite`. There are no
// synonyms: `convy suite pull` is not introduced next to `convy pull`.
package cli
import (
"fmt"
"io"
"os"
"strings"
)
// ExitCode is the exit status of the process.
type ExitCode int
const (
// OK means the check passed and the work is done.
OK ExitCode = 0
// Failed means the check found errors.
Failed ExitCode = 1
// Usage means the command was typed wrong or run in the wrong context.
Usage ExitCode = 2
)
// Env is the environment of a run. It is pulled out so that commands can be
// tested without a process.
type Env struct {
Dir string
In io.Reader
Out io.Writer
Err io.Writer
// Interactive says whether input comes from a terminal. A command that
// asks questions refuses to start without one rather than blocking on an
// answer nobody is there to give.
Interactive bool
}
// Run parses the arguments and executes the command.
func Run(env Env, args []string) ExitCode {
if len(args) == 0 {
usage(env.Out)
return Usage
}
switch args[0] {
case "suite":
return runSuite(env, args[1:])
case "init":
return runInit(env, args[1:])
case "add":
return runAdd(env, args[1:])
case "pull":
return runPull(env, args[1:])
case "sync":
return runSync(env, args[1:])
case "list":
return runList(env, args[1:])
case "check":
return runCheck(env, args[1:])
case "help", "-h", "--help":
usage(env.Out)
return OK
default:
fmt.Fprintf(env.Err, "unknown command %q\n\n", args[0])
usage(env.Err)
return Usage
}
}
func runSuite(env Env, args []string) ExitCode {
if len(args) == 0 {
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, rule, retire, list or check")
return Usage
}
switch args[0] {
case "check":
return runSuiteCheck(env, args[1:])
case "init":
return runSuiteInit(env, args[1:])
case "add":
return runSuiteAdd(env, args[1:])
case "rule":
return runSuiteRule(env, args[1:])
case "retire":
return runSuiteRetire(env, args[1:])
case "list":
return runSuiteList(env, args[1:])
default:
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
return Usage
}
}
// usage prints the help grouped under headings: a flat list hides the levels.
func usage(w io.Writer) {
fmt.Fprint(w, `convy — tending development conventions.
In a project:
convy init wire up conventions: the source and the first component
convy add <topic> subscribe and assemble
convy pull reassemble what is subscribed, text and all
convy sync make the files follow the manifest, and say what is off
convy list what is wired up and what else the suite has
convy check check the form of what is here
In a suite:
convy suite init start a suite: a directory and a manifest
convy suite add add a convention: a file, a topic and a prefix
convy suite rule add a rule: the next number, the blocks in order
convy suite retire retire a rule, a convention or a topic — never reusing it
convy suite list what the suite holds, and what a component would take
convy suite check suite integrity: prefixes, topics, axes, links, form
The commands that change something run in two modes. Bare, they ask for every
field with a hint attached — that mode is for a person. With flags, they take
everything at once and ask nothing — that mode is for agents and scripts.
`)
}
// noStrayArgs turns down an argument the command has no place for. The flag
// package stops parsing at the first argument that is not a flag, so a stray one
// does not merely sit there unused — it hides every flag written after it, and
// the command then does something other than what was asked in silence.
func noStrayArgs(env Env, name string, rest []string) ExitCode {
if len(rest) == 0 {
return OK
}
fmt.Fprintf(env.Err, "%s takes no argument, and %q was given; a component is named by --for\n", name, rest[0])
return Usage
}
// split reads a comma-separated list off the command line. An axis of a
// component is a list — a component may sit on two stacks at once — and one
// flag repeated is worse to type than one flag with commas in it.
func split(value string) []string {
var out []string
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
// Main is the entry point of the process.
func Main() int {
dir, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "cannot determine the current directory:", err)
return int(Usage)
}
env := Env{
Dir: dir,
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
Interactive: terminal(os.Stdin),
}
return int(Run(env, os.Args[1:]))
}
// terminal reports whether a file is a character device, which is as close as
// the standard library gets to asking whether a person is on the other end.
func terminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
+251
View File
@@ -0,0 +1,251 @@
package cli_test
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/suite"
)
// run executes a command the way the process does, with input and output in
// hand. Interactive says whether a terminal is pretended to be there.
func run(t *testing.T, dir, input string, interactive bool, args ...string) (cli.ExitCode, string) {
t.Helper()
var out, errOut bytes.Buffer
env := cli.Env{
Dir: dir,
In: strings.NewReader(input),
Out: &out,
Err: &errOut,
Interactive: interactive,
}
code := cli.Run(env, args)
return code, out.String() + errOut.String()
}
func read(t *testing.T, parts ...string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(parts...))
if err != nil {
t.Fatal(err)
}
return string(body)
}
// checkClean asserts that the suite the commands built passes every check.
// This is the invariant worth the most: what the tool writes, the tool accepts.
func checkClean(t *testing.T, root string) {
t.Helper()
s, err := suite.Load(root)
if err != nil {
t.Fatalf("loading the suite the commands built: %v", err)
}
rep := check.Suite(s)
if rep.Errors() > 0 || rep.Warnings() > 0 {
var b strings.Builder
for _, f := range rep.Findings() {
fmt.Fprintf(&b, " %s: %s\n", f.Path, f.Msg)
}
t.Fatalf("the suite the commands built does not check clean:\n%s", b.String())
}
}
func TestInitAndAddInAutomaticMode(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK {
t.Fatalf("suite init returned %d: %s", code, out)
}
if code, out := run(t, root, "", false, "suite", "add",
"--topic", "time", "--about", "время: хранение и форматы",
"--prefix", "TIME", "--title", "Время",
"--intro", "Как приложение записывает моменты."); code != cli.OK {
t.Fatalf("suite add returned %d: %s", code, out)
}
if code, out := run(t, root, "", false, "suite", "add",
"--topic", "time", "--prefix", "GTIM", "--lang", "go",
"--title", "Время: реализация на Go"); code != cli.OK {
t.Fatalf("suite add of a layer returned %d: %s", code, out)
}
base := read(t, root, "conventions/time.md")
for _, want := range []string{"topic: time", "prefix: TIME", "# Время", "ДОЛЖЕН", "версии 1"} {
if !strings.Contains(base, want) {
t.Errorf("the base layer lost %q:\n%s", want, base)
}
}
if strings.Contains(base, "extends:") {
t.Errorf("the base layer got an extends key:\n%s", base)
}
layer := read(t, root, "conventions/lang/go/time.md")
for _, want := range []string{"lang: go", "extends: conventions/time.md"} {
if !strings.Contains(layer, want) {
t.Errorf("the language layer lost %q:\n%s", want, layer)
}
}
checkClean(t, root)
}
// The dialogue asks in the order the fields are declared, and a new topic gets
// one extra question about what it is for.
func TestAddInInteractiveMode(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK {
t.Fatalf("suite init returned %d: %s", code, out)
}
answers := strings.Join([]string{
"logging", // topic
"логирование: уровни", // one line about a topic new to the suite
"SLOG", // prefix
"Логирование", // title
"", // language axis: the base layer
"", // stack axis
"Как приложение пишет записи.", // introductory prose
"", // path: the default offered
}, "\n") + "\n"
code, out := run(t, root, answers, true, "suite", "add")
if code != cli.OK {
t.Fatalf("the dialogue returned %d: %s", code, out)
}
if !strings.Contains(out, "One line about the topic") {
t.Errorf("a topic new to the suite was not asked about:\n%s", out)
}
body := read(t, root, "conventions/logging.md")
for _, want := range []string{"topic: logging", "prefix: SLOG", "# Логирование", "Как приложение пишет записи."} {
if !strings.Contains(body, want) {
t.Errorf("the file lost %q:\n%s", want, body)
}
}
if !strings.Contains(read(t, root, ".conventions-suite.toml"), `logging = "логирование: уровни"`) {
t.Error("the topic did not reach the manifest")
}
checkClean(t, root)
}
// A known topic is described already, so the dialogue skips that question.
func TestInteractiveSkipsTheQuestionAboutAKnownTopic(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
answers := "time\nGTIM\nВремя на Go\ngo\n\n\n\n"
code, out := run(t, root, answers, true, "suite", "add")
if code != cli.OK {
t.Fatalf("the dialogue returned %d: %s", code, out)
}
if strings.Contains(out, "One line about the topic") {
t.Errorf("a known topic was asked about again:\n%s", out)
}
if !strings.Contains(out, "the topic is known") {
t.Errorf("the dialogue did not say the topic is known:\n%s", out)
}
checkClean(t, root)
}
// Without a terminal a bare command must refuse rather than block on an answer
// nobody is there to give.
func TestBareCommandRefusesWithoutATerminal(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
code, out := run(t, root, "", false, "suite", "add")
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
if !strings.Contains(out, "no terminal") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// Automatic mode names every missing field at once: being sent back one flag at
// a time is the worst way to learn what a command wants.
func TestAutomaticModeNamesEveryMissingField(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
code, out := run(t, root, "", false, "suite", "add", "--topic", "time")
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
for _, want := range []string{"--prefix", "--title"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not name %s:\n%s", want, out)
}
}
}
func TestAddRefusesWhatTheSuiteAlreadyHolds(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
cases := []struct {
name string
args []string
want string
}{{
name: "a prefix already taken",
args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "TIME", "--title", "Конфигурация"},
want: "already taken",
}, {
name: "a prefix on the letter reserved for consumers",
args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "XCFG", "--title", "Конфигурация"},
want: "reserved for the local rules of consumers",
}, {
name: "a second base layer of one topic",
args: []string{"--topic", "time", "--prefix", "TIMB", "--title", "Время снова", "--path", "conventions/time-again.md"},
want: "already holds a base layer",
}, {
name: "a topic new to the suite and undescribed",
args: []string{"--topic", "config", "--prefix", "CONF", "--title", "Конфигурация"},
want: "--about is required",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, append([]string{"suite", "add"}, tc.args...)...)
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
checkClean(t, root)
}
func TestInitRefusesInsideASuite(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
code, out := run(t, ".", "", false, "suite", "init", "--path", root)
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
if !strings.Contains(out, "this is a suite already") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
func TestInitRefusesAnUnknownVocabulary(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "xx")
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
if !strings.Contains(out, "unknown to the tool") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
+155
View File
@@ -0,0 +1,155 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// A project manifest is data, and the tool rewrites it whole on every
// subscription. So it carries no comment: what a component is for is said in
// the documentation, which stays put, rather than in a file that a machine
// re-encodes behind the author's back.
func runInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy init", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; the current directory by default")
from := fs.String("source", "", "reference to the suite: a path on disk or a git repository")
component := fs.String("component", "", "name of the first component")
dir := fs.String("dir", "", "directory the copies of that component go into")
langAxis := fs.String("lang", "", "language of the component")
stackAxis := fs.String("stack", "", "stack of the component, comma-separated")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy init", fs.Args()); code != OK {
return code
}
where := *root
if where == "" {
where = env.Dir
}
if exists(filepath.Join(where, manifest.ProjectName)) {
fmt.Fprintf(env.Err, "%s already holds %s: the conventions are wired up already\n", where, manifest.ProjectName)
fmt.Fprintln(env.Err, "convy add subscribes to one more topic")
return Usage
}
given := map[string]string{
"source": *from, "component": *component, "dir": *dir,
"lang": *langAxis, "stack": *stackAxis,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy init without arguments asks questions, and there is no terminal to ask on; pass --source, --component and --dir")
return Usage
}
if err := askAll(env, initFields(), given); err != nil {
return Usage
}
} else if err := resolve(initFields(), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
// The suite is reached before the manifest is written. A manifest naming a
// suite nobody can reach passes every check the tool has and helps no one.
ref, err := source.Parse(given["source"])
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
tree, err := source.Open(ref, where)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
defer tree.Close()
s, err := suite.Load(tree.Dir())
if err != nil {
fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err))
return Failed
}
if err := os.MkdirAll(where, 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
name := filepath.Join(where, manifest.ProjectName)
m := &manifest.Project{
Source: given["source"],
Components: map[string]manifest.Component{
given["component"]: {
Dir: given["dir"],
Lang: split(given["lang"]),
Stack: split(given["stack"]),
Topics: []string{},
},
},
Path: name,
Root: where,
}
if err := m.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\ncreated %s\n", name)
fmt.Fprintf(env.Out, "the suite speaks %s, conventions language version %d\n",
s.Manifest.Language.Lang, s.Manifest.Language.Version)
topics := s.Manifest.LiveTopics()
if len(topics) > 0 {
fmt.Fprintf(env.Out, "\n%s to take from:\n", plural(len(topics), "topic"))
for _, t := range topics {
fmt.Fprintf(env.Out, " %-20s %s\n", t, s.Manifest.Topics.Live[t])
}
}
fmt.Fprint(env.Out, `
next:
convy add <topic> subscribe and assemble
convy list what is wired up and what else is there
`)
return OK
}
func initFields() []Field {
return []Field{{
Flag: "source",
Ask: "Where the copies come from",
Hint: "A path to the suite on disk — relative to this repository or absolute — or a git repository over http or https. A trailing #branch, #tag or #commit pins a revision.",
}, {
Flag: "component",
Ask: "Name of the component",
Hint: "A region of the repository where all the chosen layers hold at once: one language, one set of tools. The name is not internal — it is how the tool answers what it assembled and where.",
Check: func(v string) error {
if !nameRe.MatchString(v) {
return fmt.Errorf("a component is named by a plain identifier: letters, digits, a dash")
}
return nil
},
}, {
Flag: "dir",
Ask: "Directory of the copies",
Hint: "Where the assembled files go. Every component has its own, and two components never share one: the copies of a topic would collide by name.",
Default: "docs/conventions",
}, {
Flag: "lang",
Ask: "Language of the component",
Hint: "Chooses the language layers. Empty when the suite is flat and has no axes at all.",
Optional: true,
}, {
Flag: "stack",
Ask: "Stack of the component",
Hint: "Chooses the stack layers — the storage, the transport, the tools. Several are allowed, comma-separated: sqlite and postgres hold together, being different tables of one service.",
Optional: true,
}}
}
+111
View File
@@ -0,0 +1,111 @@
package cli
import (
"flag"
"fmt"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy list answers two questions in one view: what this repository takes and
// what the suite has that it does not. The second half is the reason the
// command reaches the suite at all — a listing of the manifest alone is the
// manifest, and reading it needs no tool.
func runList(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy list", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to show; every one of them by default")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy list", fs.Args()); code != OK {
return code
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
names, code := components(env, o.Manifest, *forComponent)
if code != OK {
return code
}
fmt.Fprintf(env.Out, "source %s\n", o.Manifest.Source)
fmt.Fprintf(env.Out, "%s, language version %d (%s)\n",
plural(len(o.Suite.Manifest.LiveTopics()), "topic"),
o.Suite.Manifest.Language.Version, o.Suite.Manifest.Language.Lang)
for _, name := range names {
c := o.Manifest.Components[name]
fmt.Fprintf(env.Out, "\n%s — %s%s\n", name, c.Dir, axisOf(c))
listComponent(env, o, c)
}
return OK
}
func listComponent(env Env, o *opened, c manifest.Component) {
if len(c.Topics) == 0 {
fmt.Fprintln(env.Out, " subscribed to nothing yet")
}
for _, topic := range c.Topics {
if !o.Suite.Manifest.TopicLive(topic) {
fmt.Fprintf(env.Out, " %-20s the suite declares no such topic any more\n", topic)
continue
}
taken, _ := o.Suite.Assemble(topic, project.Axis(c))
file := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
mark := " "
if !exists(filepath.Join(o.Root, filepath.FromSlash(file))) {
mark = "!"
}
fmt.Fprintf(env.Out, "%s %-20s %-16s %s\n", mark, topic, plural(len(taken), "layer"),
o.Suite.Manifest.Topics.Live[topic])
if mark == "!" {
fmt.Fprintf(env.Out, " %-20s subscribed, and no file: convy pull assembles it\n", "")
}
}
var free []string
for _, topic := range o.Suite.Manifest.LiveTopics() {
if !c.Subscribed(topic) {
free = append(free, topic)
}
}
if len(free) == 0 {
return
}
fmt.Fprintln(env.Out, "\n not taken:")
for _, topic := range free {
taken, _ := o.Suite.Assemble(topic, project.Axis(c))
if len(taken) == 0 {
fmt.Fprintf(env.Out, "· %-20s %-16s %s\n", topic, "no layer fits",
o.Suite.Manifest.Topics.Live[topic])
continue
}
fmt.Fprintf(env.Out, "· %-20s %-16s %s\n", topic, plural(len(taken), "layer"),
o.Suite.Manifest.Topics.Live[topic])
}
}
// axisOf describes the axis of a component the way the suite writes it.
func axisOf(c manifest.Component) string {
var parts []string
if len(c.Lang) > 0 {
parts = append(parts, "lang="+strings.Join(c.Lang, ","))
}
if len(c.Stack) > 0 {
parts = append(parts, "stack="+strings.Join(c.Stack, ","))
}
if len(parts) == 0 {
return ""
}
return " (" + strings.Join(parts, " ") + ")"
}
+198
View File
@@ -0,0 +1,198 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// A project command works against two levels at once: the manifest lying in the
// repository and the suite it names. Reaching the suite costs a clone when the
// reference is a git one, so it is done once per command and released at the
// end — nothing of the suite is left lying about in the project.
// opened is a project together with the levels above it.
type opened struct {
Root string
Manifest *manifest.Project
Suite *suite.Suite
// LangRoot is where the documents about the language lie. It is the root
// of the suite while the language has no repository of its own.
LangRoot string
trees []*source.Tree
}
// Close releases whatever the opening fetched.
func (o *opened) Close() {
for _, t := range o.trees {
t.Close()
}
}
// openProject finds the project, resolves its source and loads the suite.
func openProject(env Env, root string) (*opened, ExitCode) {
dir, code := projectRoot(env, root)
if code != OK {
return nil, code
}
m, code := loadProject(env, dir)
if code != OK {
return nil, code
}
if m.Source == "" {
fmt.Fprintf(env.Err, "%s names no source: a copy comes from a suite, and the manifest is where the suite is named\n", m.Path)
return nil, Usage
}
ref, err := source.Parse(m.Source)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Usage
}
tree, err := source.Open(ref, dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Failed
}
o := &opened{Root: dir, Manifest: m, LangRoot: tree.Dir(), trees: []*source.Tree{tree}}
o.Suite, err = suite.Load(tree.Dir())
if err != nil {
fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err))
o.Close()
return nil, Failed
}
// The language is a level of its own, and a suite may keep its documents
// apart from itself. While it does not, the suite is where they lie.
if spec := o.Suite.Manifest.Language.Source; spec != "" {
ref, err := source.Parse(spec)
if err != nil {
o.Close()
fmt.Fprintf(env.Err, "%s: [language] source: %s\n", o.Suite.Manifest.Path, err)
return nil, Usage
}
langTree, err := source.Open(ref, tree.Dir())
if err != nil {
o.Close()
fmt.Fprintln(env.Err, err)
return nil, Failed
}
o.trees = append(o.trees, langTree)
o.LangRoot = langTree.Dir()
}
return o, OK
}
// loadProject reads the project manifest and says what it did not understand.
// A typo in a key costs a whole component: `dyr` instead of `dir` leaves the
// component pointing at the root of the repository, and nothing else would say
// so.
func loadProject(env Env, dir string) (*manifest.Project, ExitCode) {
m, err := manifest.LoadProject(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Usage
}
for _, key := range m.Undecoded {
fmt.Fprintf(env.Err, "warning: %s: the key %s is unknown to the tool\n", m.Path, key)
}
return m, OK
}
// projectRoot finds the manifest of the project. A project command typed inside
// a suite does not do anything at a guess: it says where it is and names the
// command of that level.
func projectRoot(env Env, given string) (string, ExitCode) {
if given != "" {
return given, OK
}
found, err := manifest.FindProject(env.Dir)
if err == nil {
return found, OK
}
if _, suiteErr := manifest.Find(env.Dir); suiteErr == nil {
fmt.Fprintf(env.Err, "this is a conventions suite, not a project that takes copies: %s lies here, %s does not\n", manifest.Name, manifest.ProjectName)
fmt.Fprintln(env.Err, "the commands of a suite are under convy suite")
return "", Usage
}
fmt.Fprintf(env.Err, "not a project with conventions: no %s here or above\n", manifest.ProjectName)
fmt.Fprintln(env.Err, "convy init wires one up")
return "", Usage
}
// componentOf resolves the --for flag against the manifest.
func componentOf(env Env, m *manifest.Project, name string) (string, manifest.Component, ExitCode) {
got, c, err := m.Only(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return "", manifest.Component{}, Usage
}
if c.Dir == "" {
fmt.Fprintf(env.Err, "the component %q names no dir, and a copy has to be written somewhere\n", got)
return "", manifest.Component{}, Usage
}
return got, c, OK
}
// components picks the components a command works on: the one named by --for,
// or every one of them.
func components(env Env, m *manifest.Project, name string) ([]string, ExitCode) {
if name != "" {
if _, ok := m.Components[name]; !ok {
fmt.Fprintf(env.Err, "the project declares no component %q; it declares: %s\n", name, strings.Join(m.Names(), ", "))
return nil, Usage
}
return []string{name}, OK
}
if len(m.Components) == 0 {
fmt.Fprintf(env.Err, "%s declares no component, and a copy is assembled for a component\n", m.Path)
return nil, Usage
}
return m.Names(), OK
}
// distinctDirs checks that no two components write into the same place. Two
// copies of one topic would otherwise collide by name, and that is an error of
// the manifest rather than a reason to rename files.
//
// One directory inside another is the same error told less plainly: whatever
// walks the outer one finds the copies of the inner, and every command that
// counts files counts them twice.
func distinctDirs(m *manifest.Project) error {
dirs := make(map[string]string, len(m.Components))
for _, name := range m.Names() {
dirs[name] = filepath.ToSlash(filepath.Clean(m.Components[name].Dir))
}
names := m.Names()
for i, a := range names {
for _, b := range names[i+1:] {
switch {
case dirs[a] == dirs[b]:
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", a, b, dirs[a])
case within(dirs[a], dirs[b]):
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", b, dirs[b], a, dirs[a])
case within(dirs[b], dirs[a]):
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", a, dirs[a], b, dirs[b])
}
}
}
return nil
}
// within reports whether inner lies under outer.
func within(outer, inner string) bool {
return strings.HasPrefix(inner, outer+"/")
}
// exists reports whether a path is there.
func exists(name string) bool {
_, err := os.Stat(name)
return err == nil
}
+552
View File
@@ -0,0 +1,552 @@
package cli_test
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// addComponent puts one more component into the project manifest.
func addComponent(t *testing.T, root, name string, c manifest.Component) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
m.Components[name] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// readingGuide stands in for what a suite puts next to its copies: the short
// account of the language, naming every word the version line names.
const readingGuide = `# Как читать конвенцию
Слова толкуются так, и только когда написаны заглавными.
| Слово | Значение |
|---|---|
| ДОЛЖЕН | требование |
| НЕ ДОЛЖЕН | запрет |
| СЛЕДУЕТ | рекомендация |
| НЕ СЛЕДУЕТ | рекомендация против |
| ДОПУСКАЕТСЯ | разрешение |
| ПОЧЕМУ | обоснование |
| ПРИМЕРЫ | иллюстрация |
| МЕХАНИЗИРОВАНО | чем проверяется |
| СНЯТО | заглушка снятого правила |
`
// subscribable is a suite a project can take from: the fixture of the retire
// tests plus the guide that travels next to the copies.
func subscribable(t *testing.T) string {
t.Helper()
root := retirable(t)
appendRules(t, root, "conventions/lang/go/time.md", `
### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
`)
// A topic whose only layer sits on an axis: a component of another stack
// takes nothing of it at all. The canon has such topics, and a fixture
// where every topic has a base layer would never exercise that.
run(t, root, "", false, "suite", "add", "--topic", "web-ui", "--about", "веб-UI",
"--prefix", "HTMX", "--stack", "htmx", "--title", "Веб-UI на htmx")
appendRules(t, root, "conventions/stack/htmx/web-ui.md", `
### HTMX-1. Партиал отвечает фрагментом, а не страницей
**ДОЛЖЕН.** Обработчик свопа возвращает только заменяемый фрагмент.
**ПОЧЕМУ.** Страница целиком заставляет браузер выбросить состояние формы.
`)
name := filepath.Join(root, "READING.md")
if err := os.WriteFile(name, []byte(readingGuide), 0o644); err != nil {
t.Fatal(err)
}
edit(t, root, func(m *manifest.Manifest) { m.Language.Reading = "READING.md" })
checkClean(t, root)
return root
}
// edit changes the suite manifest the way the tool does: through the struct,
// because the file is data and carries nothing else to preserve.
func edit(t *testing.T, root string, change func(*manifest.Manifest)) {
t.Helper()
m, err := manifest.Load(root)
if err != nil {
t.Fatal(err)
}
change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// wired builds a project taking from that suite, and returns its root.
func wired(t *testing.T, suiteRoot string, args ...string) string {
t.Helper()
root := filepath.Join(t.TempDir(), "app")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
all := append([]string{"init", "--source", suiteRoot}, args...)
if code, out := run(t, root, "", false, all...); code != cli.OK {
t.Fatalf("convy init returned %d: %s", code, out)
}
return root
}
func TestInitAddAndPullBuildACopyOutOfTheLayers(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
code, out := run(t, root, "", false, "add", "time")
if code != cli.OK {
t.Fatalf("convy add returned %d: %s", code, out)
}
if !strings.Contains(out, "docs/conventions/time.md") {
t.Errorf("the assembled file is not named:\n%s", out)
}
body := read(t, root, "docs/conventions/time.md")
for _, want := range []string{
"origin: time",
"### TIME-1.",
"#### GTIM-1.",
"<!-- conv:local -->",
} {
if !strings.Contains(body, want) {
t.Errorf("the copy lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "prefix: TIME") {
t.Errorf("the front matter of a layer travelled into the copy:\n%s", body)
}
// The guide to reading a rule belongs to the suite and travels with the
// copies; README.md in the same directory belongs to the repository.
if guide := read(t, root, "docs/conventions/READING.md"); !strings.Contains(guide, "ДОПУСКАЕТСЯ") {
t.Errorf("the reading guide did not travel:\n%s", guide)
}
if manifest := read(t, root, ".conventions.toml"); !strings.Contains(manifest, `topics = ["time"]`) {
t.Errorf("the subscription was not written:\n%s", manifest)
}
if code, out := run(t, root, "", false, "check"); code != cli.OK {
t.Fatalf("checking what the tool assembled returned %d: %s", code, out)
}
}
// The whole point of the marker: what the repository wrote survives, what the
// suite wrote is replaced.
func TestPullKeepsTheLocalPartAndReplacesTheRest(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body = strings.Replace(body, "**ДОЛЖЕН.** Момент времени", "**ДОЛЖЕН.** Правка выше маркера", 1)
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "pull")
if code != cli.OK {
t.Fatalf("convy pull returned %d: %s", code, out)
}
if !strings.Contains(out, "local part kept") {
t.Errorf("the pull did not say the local part survived:\n%s", out)
}
body = read(t, root, "docs/conventions/time.md")
if strings.Contains(body, "Правка выше маркера") {
t.Errorf("an edit above the marker survived, and it is declared not to:\n%s", body)
}
if !strings.Contains(body, "МЕХАНИЗИРОВАНО: `internal/archrules`") {
t.Errorf("the local part was lost:\n%s", body)
}
}
// A layer travels when the axis it declares agrees with the component; the base
// layer travels always.
func TestAComponentTakesOnlyTheLayersThatFitIt(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "web", "--dir", "web/docs/conventions", "--lang", "javascript")
run(t, root, "", false, "add", "time")
body := read(t, root, "web/docs/conventions/time.md")
if !strings.Contains(body, "### TIME-1.") {
t.Errorf("the base layer did not travel:\n%s", body)
}
if strings.Contains(body, "GTIM-1") {
t.Errorf("a go layer travelled into a javascript component:\n%s", body)
}
}
func TestProjectCommandsRefuseWhatTheyCannotDo(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
cases := []struct {
name string
args []string
want string
}{{
name: "a topic the suite does not declare",
args: []string{"add", "billing"},
want: "declares no topic",
}, {
name: "a topic taken twice",
args: []string{"add", "time"},
want: "subscribed to",
}, {
name: "a component that is not there",
args: []string{"pull", "--for", "mobile"},
want: "no component",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, tc.args...)
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
}
// A file that lost its origin key has become a document of the repository, and
// assembly has no business overwriting it.
func TestPullDoesNotOverwriteWhatIsNoLongerACopy(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body = strings.Replace(body, "---\norigin: time\n---\n\n", "", 1)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
t.Fatalf("the pull overwrote a document of the repository:\n%s", out)
}
if !strings.Contains(out, "no origin key") {
t.Errorf("the refusal does not say why:\n%s", out)
}
if got := read(t, root, "docs/conventions/time.md"); strings.Contains(got, "origin: time") {
t.Errorf("the file was rewritten anyway:\n%s", got)
}
}
// The context is read off the manifest lying next to you, and nothing is done
// at a guess.
func TestAProjectCommandInsideASuiteSaysWhereItIs(t *testing.T) {
suiteRoot := subscribable(t)
code, out := run(t, suiteRoot, "", false, "pull")
if code == cli.OK {
t.Fatalf("convy pull ran inside a suite:\n%s", out)
}
if !strings.Contains(out, "convy suite") {
t.Errorf("the refusal does not point at the commands of a suite:\n%s", out)
}
}
func TestListShowsWhatIsTakenAndWhatIsNot(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
code, out := run(t, root, "", false, "list")
if code != cli.OK {
t.Fatalf("convy list returned %d: %s", code, out)
}
for _, want := range []string{"backend — docs/conventions", "lang=go", "time", "2 layers", "not taken", "logging"} {
if !strings.Contains(out, want) {
t.Errorf("the listing lacks %q:\n%s", want, out)
}
}
}
// The topic stands in front of the flags, and the flag package stops at the
// first argument that is not one — so "convy add time --for backend", the form
// the model writes, has to keep the component rather than drop it in silence.
func TestAddTakesTheTopicBeforeTheFlags(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "backend/docs", "--lang", "go")
addComponent(t, root, "web", manifest.Component{Dir: "web/docs", Topics: []string{}})
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
if code != cli.OK {
t.Fatalf("convy add returned %d: %s", code, out)
}
if !strings.Contains(out, "web/docs/logging.md") {
t.Errorf("the copy did not land in the named component:\n%s", out)
}
if manifest := read(t, root, ".conventions.toml"); !strings.Contains(manifest, `topics = ["logging"]`) {
t.Errorf("the subscription went to the wrong component:\n%s", manifest)
}
}
// Distinct directories are the only thing that tells two copies of one topic
// apart, so sharing one is an error of the manifest rather than a reason to
// rename files.
func TestTwoComponentsMayNotShareADirectory(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
addComponent(t, root, "web", manifest.Component{Dir: "docs/conventions", Topics: []string{}})
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
t.Fatalf("two components wrote into one directory:\n%s", out)
}
if !strings.Contains(out, "share the directory") {
t.Errorf("the refusal does not say what collides:\n%s", out)
}
}
// Bare, a project command asks; that mode is for a person, and the one with
// flags is for agents and scripts.
func TestAddAsksWhichTopicWhenToldNothing(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
code, out := run(t, root, "time\n", true, "add")
if code != cli.OK {
t.Fatalf("the dialogue returned %d: %s", code, out)
}
if !strings.Contains(out, "Topic") || !strings.Contains(out, "время") {
t.Errorf("the question carries no hint about what is on offer:\n%s", out)
}
if !strings.Contains(out, "docs/conventions/time.md") {
t.Errorf("the dialogue assembled nothing:\n%s", out)
}
// Without a terminal the same bare command refuses instead of blocking on
// an answer nobody is there to give.
code, out = run(t, root, "", false, "add")
if code == cli.OK {
t.Fatalf("a bare command went through with no terminal:\n%s", out)
}
if !strings.Contains(out, "no terminal") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// The language is a level of its own, and a suite may keep its documents apart.
// Nothing else changes: the guide still travels next to the copies.
func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
suiteRoot := subscribable(t)
apart := filepath.Join(filepath.Dir(suiteRoot), "language")
if err := os.MkdirAll(apart, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(suiteRoot, "READING.md"), filepath.Join(apart, "READING.md")); err != nil {
t.Fatal(err)
}
edit(t, suiteRoot, func(m *manifest.Manifest) { m.Language.Source = "../language" })
checkClean(t, suiteRoot)
// A check that did not run says so: silence would read as a check passed.
if _, out := run(t, suiteRoot, "", false, "suite", "check"); !strings.Contains(out, "went unchecked") {
t.Errorf("the check did not say it left the language alone:\n%s", out)
}
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
if code, out := run(t, root, "", false, "add", "time"); code != cli.OK {
t.Fatalf("convy add returned %d: %s", code, out)
}
if guide := read(t, root, "docs/conventions/READING.md"); !strings.Contains(guide, "ДОПУСКАЕТСЯ") {
t.Errorf("the guide did not come from the level it lives on:\n%s", guide)
}
}
// The rules of the repository take a prefix on X and live below the marker. One
// standing above it would be wiped by the next pull, and saying so afterwards
// is too late.
func TestCheckCatchesALocalRuleAboveTheMarker(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
local := "\n### XTIM-1. Часы в тестах замораживаются\n\n" +
"**ДОЛЖЕН.** Тест берёт время у подменённого `store.Now`.\n\n" +
"**ПОЧЕМУ.** Плавающее время делает падение теста невоспроизводимым.\n"
if err := os.WriteFile(name, []byte(body+local), 0o644); err != nil {
t.Fatal(err)
}
if code, out := run(t, root, "", false, "check"); code != cli.OK {
t.Fatalf("a local rule below the marker was turned down: %d\n%s", code, out)
}
above := strings.Replace(body, "<!-- conv:local -->", local+"\n<!-- conv:local -->", 1)
if err := os.WriteFile(name, []byte(above), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "check")
if code == cli.OK {
t.Fatalf("a rule of the repository above the marker went unnoticed:\n%s", out)
}
if !strings.Contains(out, "would wipe it") {
t.Errorf("the finding does not say what is at stake:\n%s", out)
}
}
// copyClean is the project half of the invariant worth the most: what the tool
// writes, the tool accepts — and accepts in silence, warnings included.
func copyClean(t *testing.T, root, dir string) {
t.Helper()
var docs []*doc.Document
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dir)))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if filepath.Ext(e.Name()) != ".md" {
continue
}
rel := path.Join(dir, e.Name())
d, err := doc.Load(rel, filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("loading %s: %v", rel, err)
}
if d.Front.Origin != "" {
docs = append(docs, d)
}
}
if len(docs) == 0 {
t.Fatalf("no copy was assembled in %s", dir)
}
rep := check.Copies(docs)
if len(rep.Findings()) == 0 {
return
}
var b strings.Builder
for _, f := range rep.Findings() {
fmt.Fprintf(&b, " %s: %s\n", f.Path, f.Msg)
}
t.Fatalf("the copies the tool assembled do not check clean:\n%s", b.String())
}
func TestWhatTheToolAssemblesTheToolAccepts(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
run(t, root, "", false, "add", "logging")
copyClean(t, root, "docs/conventions")
}
// The flag package stops at the first argument that is not a flag, so a stray
// one hides every flag written after it and the command quietly does something
// else than what was asked.
func TestProjectCommandsTurnDownAStrayArgument(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
for _, args := range [][]string{
{"pull", "backend"},
{"list", "backend"},
{"check", "backend"},
{"init", "somewhere"},
} {
t.Run(args[0], func(t *testing.T) {
code, out := run(t, root, "", false, args...)
if code == cli.OK {
t.Fatalf("the stray argument went through:\n%s", out)
}
if !strings.Contains(out, "takes no argument") {
t.Errorf("the refusal does not say what is wrong:\n%s", out)
}
})
}
}
// The marker is always left by the assembler, so a copy without one was edited
// by hand — and everything in it would count as suite text to be replaced.
func TestPullRefusesACopyWhoseMarkerIsGone(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body = strings.Replace(body, "<!-- conv:local -->", "Заметка, написанная руками.", 1)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
t.Fatalf("the pull went ahead over a hand-edited copy:\n%s", out)
}
if !strings.Contains(out, "carries no <!-- conv:local --> marker") {
t.Errorf("the refusal does not say why:\n%s", out)
}
if got := read(t, root, "docs/conventions/time.md"); !strings.Contains(got, "Заметка, написанная руками") {
t.Errorf("the hand-written text was destroyed anyway:\n%s", got)
}
}
// Whatever the tool writes into the manifest it has to read back, and a
// backslash is the ordinary way that fails.
func TestInitWritesAManifestItCanRead(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", `docs\conventions`)
code, out := run(t, root, "", false, "list")
if code != cli.OK {
t.Fatalf("the manifest the tool wrote does not parse: %d\n%s", code, out)
}
if !strings.Contains(out, `docs\conventions`) {
t.Errorf("the directory came back changed:\n%s", out)
}
}
// A typo in a key costs a whole component, and the parser knows about it — so
// every command that reads the manifest has to pass that on.
func TestCheckSaysWhatItDidNotUnderstand(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
body := read(t, root, ".conventions.toml")
body = strings.Replace(body, `dir = "docs/conventions"`, `dyr = "docs/conventions"`, 1)
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "check")
if !strings.Contains(out, "dyr") {
t.Errorf("the typo in the manifest went unmentioned:\n%s", out)
}
if code == cli.OK {
t.Errorf("a component with no dir was checked anyway:\n%s", out)
}
}
+153
View File
@@ -0,0 +1,153 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"slices"
"strings"
)
// Commands that change something run in two modes, and which one is meant is
// read off the command line: bare means interactive, any flag means automatic.
//
// The two modes address two different callers. A person types the command and
// is walked through the fields with a hint for each; an agent or a script
// passes every field at once and must never be blocked waiting on a terminal
// that is not there. Neither mode guesses: a field that is required and absent
// stops the run in both.
// Field is one thing a command asks for.
type Field struct {
// Flag is the name the field carries on the command line.
Flag string
// Ask is the question put to a person.
Ask string
// Hint is the one line explaining what belongs here and why.
Hint string
// Default is offered as the answer when the person just presses enter.
Default string
// Optional fields may stay empty.
Optional bool
// Options, when given, are the only answers accepted.
Options []string
// Check validates an answer; it is applied in both modes.
Check func(string) error
}
// errStop ends the dialogue on end of input.
var errStop = errors.New("input ended")
// dialogue asks the fields one by one, re-asking what did not pass validation.
type dialogue struct {
in *bufio.Reader
out io.Writer
}
func newDialogue(env Env) *dialogue {
return &dialogue{in: bufio.NewReader(env.In), out: env.Out}
}
// ask puts one question and returns the answer, looping until it validates.
func (d *dialogue) ask(f Field) (string, error) {
for {
if f.Hint != "" {
fmt.Fprintf(d.out, "\n%s\n", f.Hint)
}
prompt := f.Ask
switch {
case len(f.Options) > 0:
prompt += fmt.Sprintf(" (%s)", strings.Join(f.Options, " / "))
if f.Default != "" {
prompt += fmt.Sprintf(" [%s]", f.Default)
}
case f.Default != "":
prompt += fmt.Sprintf(" [%s]", f.Default)
case f.Optional:
prompt += " [may stay empty]"
}
fmt.Fprintf(d.out, "%s: ", prompt)
line, err := d.in.ReadString('\n')
answer := strings.TrimSpace(line)
if answer == "" && err != nil {
return "", errStop
}
if answer == "" {
answer = f.Default
}
if answer == "" && !f.Optional {
fmt.Fprintln(d.out, " the field is required")
continue
}
if err := validate(f, answer); err != nil {
fmt.Fprintf(d.out, " %s\n", err)
continue
}
return answer, nil
}
}
// confirm asks a yes-or-no question before something irreversible, defaulting
// to no: a retirement that went through because the reader pressed enter is the
// one mistake the model cannot undo.
func (d *dialogue) confirm(question string) (bool, error) {
fmt.Fprintf(d.out, "\n%s [y/N]: ", question)
line, err := d.in.ReadString('\n')
answer := strings.ToLower(strings.TrimSpace(line))
if answer == "" && err != nil {
return false, errStop
}
return answer == "y" || answer == "yes", nil
}
// validate applies a field's check to a value that is not empty.
func validate(f Field, value string) error {
if value == "" {
return nil
}
// Options are what the dialogue offers; a check, where there is one, is
// what decides. A field may well accept more than it suggests — naming a
// step by its category is the suggestion, naming it by the word of the
// suite is accepted all the same.
if f.Check != nil {
return f.Check(value)
}
if len(f.Options) > 0 && !slices.Contains(f.Options, value) {
return fmt.Errorf("one of: %s", strings.Join(f.Options, ", "))
}
return nil
}
// resolve settles the fields in automatic mode: what came on the command line
// is validated, what is required and missing is named. Every missing field is
// reported at once — being sent back one flag at a time is the worst way to
// learn what a command wants.
func resolve(fields []Field, given map[string]string) error {
var missing []string
var problems []string
for _, f := range fields {
value := given[f.Flag]
if value == "" {
value = f.Default
given[f.Flag] = value
}
if value == "" {
if !f.Optional {
missing = append(missing, "--"+f.Flag)
}
continue
}
if err := validate(f, value); err != nil {
problems = append(problems, fmt.Sprintf("--%s: %s", f.Flag, err))
}
}
if len(missing) > 0 {
problems = append(problems, "required and missing: "+strings.Join(missing, ", "))
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "\n"))
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"flag"
"fmt"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy pull reassembles what the manifest lists. It never reports what
// changed: after a pull that is shown by git diff, and the decision to accept,
// to fix or to roll back is taken by a person before the commit. A second
// mechanism for comparing files, standing next to git, would answer the same
// question worse.
func runPull(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy pull", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to reassemble; every one of them by default")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy pull", fs.Args()); code != OK {
return code
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
if err := distinctDirs(o.Manifest); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
names, code := components(env, o.Manifest, *forComponent)
if code != OK {
return code
}
failed := 0
written := 0
for i, name := range names {
c := o.Manifest.Components[name]
if i > 0 {
fmt.Fprintln(env.Out)
}
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
if c.Dir == "" {
fmt.Fprintln(env.Err, " the component names no dir, and a copy has to be written somewhere")
failed++
continue
}
if len(c.Topics) == 0 {
fmt.Fprintln(env.Out, " subscribed to nothing yet")
}
// The path column is sized to the component rather than guessed: a
// name that overruns a fixed width breaks every row below it.
width := len(c.Dir) + 1 + len(project.ReadingName)
for _, topic := range c.Topics {
width = max(width, len(c.Dir)+len(topic)+4)
}
for _, topic := range c.Topics {
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
failed++
continue
}
written++
fmt.Fprintf(env.Out, " %-*s %s%s\n", width, made.Path, plural(len(made.Layers), "layer"), kept(made))
}
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
if err != nil {
fmt.Fprintf(env.Err, " %s\n", err)
failed++
continue
}
fmt.Fprintf(env.Out, " %-*s the guide to reading a rule\n", width, guide)
}
fmt.Fprintf(env.Out, "\n%s assembled", plural(written, "file"))
if failed > 0 {
fmt.Fprintf(env.Out, ", %s\n", plural(failed, "failure"))
return Failed
}
fmt.Fprintln(env.Out)
fmt.Fprintln(env.Out, "git diff says what changed")
return OK
}
// kept notes that a local part was carried over, because that is the one thing
// a reassembly could have destroyed and did not.
func kept(c project.Copy) string {
switch {
case c.Created:
return ", new"
case c.Kept:
return ", local part kept"
}
return ""
}
+228
View File
@@ -0,0 +1,228 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
// retirable builds a suite with two topics, three conventions and a couple of
// rules, which is the least that lets every case of retirement be told apart.
func retirable(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
run(t, root, "", false, "suite", "add", "--topic", "time", "--prefix", "GTIM", "--lang", "go", "--title", "Время на Go")
run(t, root, "", false, "suite", "add", "--topic", "logging", "--about", "логирование", "--prefix", "SLOG", "--title", "Логирование")
appendRules(t, root, "conventions/time.md", `
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
### TIME-2. Ширина строки фиксируется
**ДОЛЖЕН.** Внутри одной колонки длина строки времени одна.
**ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди
строк одинаковой длины.
`)
appendRules(t, root, "conventions/logging.md", `
### SLOG-1. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
`)
return root
}
func appendRules(t *testing.T, root, rel, rules string) {
t.Helper()
name := filepath.Join(root, filepath.FromSlash(rel))
body, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, append(body, []byte("\n## Правила\n"+rules)...), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRetireRuleLeavesAStub(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-2", "--reason", "ширина следует из TIME-1 и отдельного правила не требует",
"--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
if !strings.Contains(body, "### TIME-2. Ширина строки фиксируется") {
t.Errorf("the heading and the number did not survive:\n%s", body)
}
if !strings.Contains(body, "**СНЯТО 2026-07-27.** ширина следует из TIME-1") {
t.Errorf("the stub is not there or is worded wrong:\n%s", body)
}
if strings.Contains(body, "Лексикографическая сортировка") {
t.Errorf("the rationale of the retired rule survived:\n%s", body)
}
if strings.Contains(body, "**ДОЛЖЕН.** Внутри одной колонки") {
t.Errorf("the norm of the retired rule survived:\n%s", body)
}
// The numbering stays contiguous and the whole suite still checks clean —
// which is the point of a stub over a deletion.
checkClean(t, root)
}
func TestRetireRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "уже снято", "--date", "2026-07-27")
cases := []struct{ name, id, want string }{
{"a rule already retired", "TIME-2", "retired already"},
{"a number no rule carries", "TIME-9", "holds no rule numbered 9"},
{"a prefix the suite does not declare", "ZZZZ-1", "no live prefix ZZZZ"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, "suite", "retire", "--rule", tc.id, "--reason", "почему-то")
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
}
// A file takes its rules with it, and nothing stands in for a file the way a
// stub stands in for a rule. So a convention still pointed at cannot go.
func TestRetirePrefixRefusesWhileStillPointedAt(t *testing.T) {
root := retirable(t)
name := filepath.Join(root, "conventions", "logging.md")
body, _ := os.ReadFile(name)
if err := os.WriteFile(name, append(body,
[]byte("\nВремя в записи следует TIME-1.\n")...), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "тема закрыта")
if code == cli.OK {
t.Fatalf("a convention still pointed at was retired:\n%s", out)
}
for _, want := range []string{"still pointed at", "conventions/logging.md", "TIME-1"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
if _, err := os.Stat(filepath.Join(root, "conventions", "time.md")); err != nil {
t.Error("the file was removed despite the refusal")
}
}
func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--prefix", "SLOG", "--reason", "тема свёрнута", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a convention returned %d: %s", code, out)
}
if _, err := os.Stat(filepath.Join(root, "conventions", "logging.md")); !os.IsNotExist(err) {
t.Error("the file stayed behind")
}
toml := read(t, root, ".conventions-suite.toml")
if strings.Contains(toml, `SLOG = "conventions/logging.md"`) {
t.Errorf("the prefix stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `SLOG = "2026-07-27, was conventions/logging.md: тема свёрнута"`) {
t.Errorf("the retired half does not carry the date, the path and the reason:\n%s", toml)
}
if !strings.Contains(out, "no layer left") {
t.Errorf("the report does not point out that the topic is now empty:\n%s", out)
}
}
// A topic lives as long as one layer of it does, so the name cannot go first.
func TestRetireTopicWaitsForItsLayers(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "не нужна")
if code == cli.OK {
t.Fatalf("a topic with layers was retired:\n%s", out)
}
for _, want := range []string{"still holds layers", "conventions/time.md", "conventions/lang/go/time.md"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
run(t, root, "", false, "suite", "retire", "--prefix", "GTIM", "--reason", "слой снят", "--date", "2026-07-27")
run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "слой снят", "--date", "2026-07-27")
code, out = run(t, root, "", false, "suite", "retire",
"--topic", "time", "--reason", "решение переехало в logging", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring an empty topic returned %d: %s", code, out)
}
toml := read(t, root, ".conventions-suite.toml")
if strings.Contains(toml, `time = "время"`) {
t.Errorf("the topic stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `time = "2026-07-27: решение переехало в logging"`) {
t.Errorf("the retired half does not carry the date and the reason:\n%s", toml)
}
checkClean(t, root)
}
func TestRetireNeedsExactlyOneTarget(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-1", "--topic", "time", "--reason", "и то и другое")
if code == cli.OK {
t.Fatalf("two targets at once went through:\n%s", out)
}
if !strings.Contains(out, "exactly one") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// Retirement is irreversible, so the dialogue asks before writing and takes
// silence for no.
func TestRetireInteractiveConfirms(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "rule\nTIME-2\nбольше не нужно\n\n", true, "suite", "retire")
if code == cli.OK {
t.Fatalf("an unconfirmed retirement went through:\n%s", out)
}
if !strings.Contains(out, "nothing was written") {
t.Errorf("the report does not say that nothing happened:\n%s", out)
}
if strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("the file was written despite no confirmation")
}
code, out = run(t, root, "rule\nTIME-2\nбольше не нужно\ny\n", true, "suite", "retire")
if code != cli.OK {
t.Fatalf("a confirmed retirement returned %d: %s", code, out)
}
if !strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("a confirmed retirement wrote nothing")
}
}
+201
View File
@@ -0,0 +1,201 @@
package cli_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
func TestRuleTakesTheNextNumberAndKeepsTheOrderOfBlocks(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Точность носителя фиксируется",
"--modality", "requirement",
"--norm", "У колонки БД и у потока логов точность объявлена и не плавает.",
"--why", "Плавающая точность ломает сортировку выборочно и невоспроизводимо.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number is not the next after the highest:\n%s", out)
}
body := read(t, root, "conventions/time.md")
want := "### TIME-3. Точность носителя фиксируется\n\n**ДОЛЖЕН.** У колонки БД и у потока логов точность объявлена и не плавает.\n\n**ПОЧЕМУ.** Плавающая точность ломает сортировку выборочно и невоспроизводимо."
if !strings.Contains(body, want) {
t.Errorf("the rule is not laid out as the language fixes it:\n%s", body)
}
checkClean(t, root)
}
// A number is never reused, so retiring a rule does not free its number: the
// next one still comes after the highest.
func TestRuleNumbersPastARetiredOne(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "не нужно", "--date", "2026-07-27")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма.", "--why", "Причина.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number of a retired rule was reused:\n%s", out)
}
checkClean(t, root)
}
// Order in a file goes by reading rather than by number, so a rule elaborating
// another has to be placeable next to it.
func TestRulePlacesAfterTheOneItElaborates(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма третьего.", "--why", "Причина третьего.")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Уточнение первого", "--modality", "permission",
"--norm", "Норма четвёртого.", "--why", "Причина четвёртого.",
"--after", "TIME-1")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
order := []string{"### TIME-1.", "### TIME-4.", "### TIME-2.", "### TIME-3."}
at := 0
for _, id := range order {
i := strings.Index(body[at:], id)
if i < 0 {
t.Fatalf("%s is missing or out of place:\n%s", id, body)
}
at += i
}
checkClean(t, root)
}
// The step is named by its category rather than by a word of any one language,
// so that a caller need not know which language the suite is written in. The
// word itself is taken too, for whoever has it at hand.
func TestRuleTakesTheStepByCategoryOrByWord(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через категорию", "--modality", "not-recommended",
"--norm", "Норма.", "--why", "Причина.")
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через слово", "--modality", "ДОПУСКАЕТСЯ",
"--norm", "Норма.", "--why", "Причина.")
body := read(t, root, "conventions/logging.md")
for _, want := range []string{"**НЕ СЛЕДУЕТ.** Норма.", "**ДОПУСКАЕТСЯ.** Норма."} {
if !strings.Contains(body, want) {
t.Errorf("the step did not render as %q:\n%s", want, body)
}
}
checkClean(t, root)
}
func TestRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
cases := []struct {
name string
args []string
want string
}{{
name: "a prefix the suite does not declare",
args: []string{"--prefix", "ZZZZ", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П"},
want: "no live prefix ZZZZ",
}, {
name: "a step that is not on the scale",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "maybe", "--norm", "Н", "--why", "П"},
want: "one of: requirement, prohibition",
}, {
name: "placing after a rule of another file",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П", "--after", "SLOG-1"},
want: "belongs to another file",
}, {
name: "a rationale left out",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н"},
want: "--why",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, append([]string{"suite", "rule"}, tc.args...)...)
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
checkClean(t, root)
}
func TestListShowsTheSuiteBaseLayerFirst(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
for _, want := range []string{"time — время", "logging — логирование", "TIME", "GTIM", "lang=go", "base"} {
if !strings.Contains(out, want) {
t.Errorf("the listing lacks %q:\n%s", want, out)
}
}
if strings.Index(out, "conventions/time.md") > strings.Index(out, "conventions/lang/go/time.md") {
t.Errorf("a language layer came before the base one:\n%s", out)
}
}
// The selection is the read-only half of assembly: a layer travels when its
// axis keys agree with the component, and the base layer travels always.
func TestListSelectsWhatAComponentWouldTake(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "go")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if strings.Contains(out, "left out") {
t.Errorf("a component of the right language left a layer out:\n%s", out)
}
code, out = run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "python")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "conventions/lang/go/time.md") || !strings.Contains(out, "left out") {
t.Errorf("the go layer was not left out for a python component:\n%s", out)
}
if !strings.Contains(out, "conventions/time.md") {
t.Errorf("the base layer did not travel:\n%s", out)
}
}
func TestListShowsRetiredNames(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--retired")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "nothing has been retired yet") {
t.Errorf("an untouched suite reported retirements:\n%s", out)
}
run(t, root, "", false, "suite", "retire", "--prefix", "SLOG", "--reason", "свёрнута", "--date", "2026-07-27")
_, out = run(t, root, "", false, "suite", "list", "--retired")
for _, want := range []string{"SLOG", "2026-07-27", "свёрнута", "ever handed out again"} {
if !strings.Contains(out, want) {
t.Errorf("the listing of retired names lacks %q:\n%s", want, out)
}
}
}
+367
View File
@@ -0,0 +1,367 @@
package cli
import (
"flag"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
var (
nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
)
func runSuiteAdd(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite add", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
topic := fs.String("topic", "", "name of the topic the convention belongs to")
about := fs.String("about", "", "one line about the topic, for a new topic only")
prefix := fs.String("prefix", "", "prefix of the rules, four uppercase Latin letters")
title := fs.String("title", "", "title of the document")
intro := fs.String("intro", "", "introductory prose, one or two sentences")
langAxis := fs.String("lang", "", "language axis of the layer; empty means the base layer")
stackAxis := fs.String("stack", "", "stack axis of the layer; empty means the base layer")
file := fs.String("path", "", "path of the file from the root of the suite")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
given := map[string]string{
"topic": *topic, "about": *about, "prefix": *prefix,
"title": *title, "intro": *intro,
"lang": *langAxis, "stack": *stackAxis, "path": *file,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite add without arguments asks questions, and there is no terminal to ask on; pass --topic, --prefix and --title")
return Usage
}
if err := askAdd(env, s, given); err != nil {
return Usage
}
} else {
if err := resolve(addFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if given["path"] == "" {
given["path"] = defaultPath(given)
}
if err := checkTopicAbout(s, given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
}
return writeConvention(env, s, given)
}
// addFields describes what a convention needs to be added. The checks live here
// rather than at the point of writing so that both modes apply the same ones.
func addFields(s *suite.Suite) []Field {
return []Field{{
Flag: "topic",
Ask: "Topic",
Hint: "The focus the rules are about: time, config, db-schema. A topic is the unit of subscription — a consumer takes it whole. The name never changes and is never reused.",
Check: func(v string) error {
if !nameRe.MatchString(v) {
return fmt.Errorf("a topic name travels into the file system of a consumer, so it is written in Latin letters and digits")
}
if s.Manifest.TopicRetired(v) {
return fmt.Errorf("the topic %q is retired and cannot be handed out again", v)
}
return nil
},
}, {
Flag: "prefix",
Ask: "Prefix of the rules",
Hint: "Four uppercase Latin letters, unique across the suite, one per file. Pick a word that reads, not a formula: the prefix exists to be searched for. Rules will be numbered PREFIX-1, PREFIX-2.",
Check: func(v string) error {
if err := manifest.ValidPrefix(v); err != nil {
return err
}
if _, taken := s.Manifest.PathOf(v); taken {
return fmt.Errorf("the prefix %s is already taken in the suite", v)
}
if s.Manifest.PrefixRetired(v) {
return fmt.Errorf("the prefix %s is retired and is never reissued", v)
}
return nil
},
}, {
Flag: "title",
Ask: "Title of the document",
Hint: "The level-one heading, in the language of the suite.",
}, {
Flag: "lang",
Ask: "Language axis",
Hint: "The programming language this layer is about, if it is about one: go, python. Leave empty for the base layer, the one that reaches every copy.",
Optional: true,
Check: axisCheck,
}, {
Flag: "stack",
Ask: "Stack axis",
Hint: "The tool or framework this layer is about, if it is about one: htmx, ansible. Leave empty together with the language axis to get the base layer.",
Optional: true,
Check: axisCheck,
}, {
Flag: "intro",
Ask: "Introductory prose",
Hint: "One or two sentences on what the convention covers. Key words in capitals do not belong here: prose is never a norm.",
Optional: true,
}}
}
func axisCheck(v string) error {
if !nameRe.MatchString(v) {
return fmt.Errorf("an axis value becomes a directory name, so it is written in Latin letters and digits")
}
return nil
}
// askAdd walks the dialogue, asking for the topic description only when the
// topic is new and for the path once the axis is known.
func askAdd(env Env, s *suite.Suite, given map[string]string) error {
d := newDialogue(env)
fields := addFields(s)
for _, f := range fields {
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
if f.Flag != "topic" {
continue
}
if s.Manifest.TopicLive(answer) {
fmt.Fprintf(env.Out, " the topic is known, this will be another layer of it\n")
continue
}
about, err := d.ask(Field{
Flag: "about",
Ask: "One line about the topic",
Hint: "It goes into the manifest and builds the table of conventions in a consumer's README.",
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given["about"] = about
}
answer, err := d.ask(Field{
Flag: "path",
Ask: "Path of the file",
Hint: "Where the file lies in the suite. The directory tree documents the tie between layers for a human; what a layer actually is comes from the front matter.",
Default: defaultPath(given),
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given["path"] = answer
return nil
}
// checkTopicAbout guards the one field whose need depends on another: a new
// topic has to be described, a known one is described already.
func checkTopicAbout(s *suite.Suite, given map[string]string) error {
if s.Manifest.TopicLive(given["topic"]) {
return nil
}
if given["about"] == "" {
return fmt.Errorf("the topic %q is new to the suite, so --about is required: the line describes it in the manifest and in a consumer's README", given["topic"])
}
return nil
}
// defaultPath puts a file where the axis says it belongs. The path is
// documentation, not a declaration — but documentation that agrees with the
// declaration costs nothing to produce.
func defaultPath(given map[string]string) string {
topic := given["topic"]
switch {
case given["lang"] != "":
return path.Join("conventions/lang", given["lang"], topic+".md")
case given["stack"] != "":
return path.Join("conventions/stack", given["stack"], topic+".md")
}
return path.Join("conventions", topic+".md")
}
// writeConvention writes the file and splices the manifest. The file goes first
// and the manifest second, because a file the manifest does not declare is
// reported by the check, while a declared file that is missing is an error the
// author has to undo by hand.
func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode {
if given["lang"] != "" && given["stack"] != "" {
// Both axes at once is a layer meaningful only when language and tool
// coincide. The model allows it; the default path does not express
// it, so the path has to be given explicitly.
if given["path"] == "" {
fmt.Fprintln(env.Err, "a layer on both axes needs --path: the tree cannot express two axes at once")
return Usage
}
}
rel := filepath.ToSlash(given["path"])
name := filepath.Join(s.Root, filepath.FromSlash(rel))
if _, err := os.Stat(name); err == nil {
fmt.Fprintf(env.Err, "%s already exists\n", rel)
return Usage
}
base := baseLayerOf(s, given["topic"])
if base == "" && given["lang"] == "" && given["stack"] == "" {
// The first layer of a topic and no axis: this is the base one.
} else if base == "" {
fmt.Fprintf(env.Out, "note: the topic has no base layer yet, so this one stands alone\n")
} else if given["lang"] == "" && given["stack"] == "" {
fmt.Fprintf(env.Err, "the topic %q already holds a base layer (%s): a second one would put two base texts in one assembled file\n", given["topic"], base)
return Usage
}
body := conventionSkeleton(s, given, base, rel)
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if !s.Manifest.TopicLive(given["topic"]) {
s.Manifest.Topics.Add(given["topic"], given["about"])
}
s.Manifest.Prefixes.Add(given["prefix"], rel)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
return Failed
}
fmt.Fprintf(env.Out, "\ncreated %s\n", rel)
fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"])
if given["about"] != "" {
fmt.Fprintf(env.Out, ", topic %s", given["topic"])
}
fmt.Fprintf(env.Out, "\n\nwrite the first rule as ### %s-1, then run convy suite check\n", given["prefix"])
if !kebabRe.MatchString(given["topic"]) {
fmt.Fprintf(env.Out, "note: lower kebab-case is the recommended shape for a topic name\n")
}
return OK
}
// baseLayerOf returns the path of the base layer of a topic, if the suite holds
// one.
func baseLayerOf(s *suite.Suite, topic string) string {
for _, d := range s.Layers(topic) {
if !d.Front.Axis() {
return d.Path
}
}
return ""
}
// conventionSkeleton builds the file: front matter, title, prose and the
// language version line. The line is rendered out of the suite's vocabulary,
// which is the whole point of the vocabulary being a property of the language
// rather than of the code — what the tool writes, the tool also accepts.
func conventionSkeleton(s *suite.Suite, given map[string]string, base, rel string) string {
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "topic: %s\n", given["topic"])
fmt.Fprintf(&b, "prefix: %s\n", given["prefix"])
if given["lang"] != "" {
fmt.Fprintf(&b, "lang: %s\n", given["lang"])
}
if given["stack"] != "" {
fmt.Fprintf(&b, "stack: %s\n", given["stack"])
}
if base != "" {
fmt.Fprintf(&b, "extends: %s\n", extendsRef(s, base, rel))
}
b.WriteString("---\n\n")
fmt.Fprintf(&b, "# %s\n\n", given["title"])
if given["intro"] != "" {
fmt.Fprintf(&b, "%s\n\n", given["intro"])
}
b.WriteString(s.Vocab.VersionLine())
b.WriteString("\n")
return b.String()
}
// extendsRef writes the base layer the way the suite already writes it: from
// the directory the two files share, when that form names one file and no more.
//
// The short form is what a reader expects, but two layers of one topic usually
// carry the same file name, so a tail like "time.md" can fit both the base
// layer and the file being written. Where that happens the reference falls back
// to the path from the root of the suite, which fits exactly one file always.
func extendsRef(s *suite.Suite, base, rel string) string {
short := base
shared := path.Dir(rel)
for shared != "." && shared != "/" {
if rest, ok := strings.CutPrefix(base, path.Dir(shared)+"/"); ok {
short = rest
break
}
shared = path.Dir(shared)
}
if short == base || unambiguous(s, short, base, rel) {
return short
}
return base
}
// unambiguous reports whether a tail names the base layer and nothing else,
// counting the file about to be written as one of the suite's own.
func unambiguous(s *suite.Suite, short, base, rel string) bool {
paths := []string{rel}
for _, d := range s.Docs {
paths = append(paths, d.Path)
}
hits := 0
for _, p := range paths {
if p == short || strings.HasSuffix(p, "/"+short) {
hits++
}
}
return hits == 1 && (base == short || strings.HasSuffix(base, "/"+short))
}
// suiteRoot settles the directory a suite command works in.
func suiteRoot(env Env, given string) (string, ExitCode) {
if given != "" {
return given, OK
}
found, err := manifest.Find(env.Dir)
if err != nil {
fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name)
fmt.Fprintln(env.Err, "convy suite init starts one")
return "", Usage
}
return found, OK
}
+133
View File
@@ -0,0 +1,133 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
func runSuiteCheck(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite check", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory")
quiet := fs.Bool("quiet", false, "print findings only")
asJSON := fs.Bool("json", false, "write the findings as JSON, for a caller that is not a person")
if err := fs.Parse(args); err != nil {
return Usage
}
dir := *root
if dir == "" {
found, err := manifest.Find(env.Dir)
if err != nil {
if errors.Is(err, manifest.ErrNotFound) {
fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name)
if _, err := os.Stat(filepath.Join(env.Dir, ".conventions.toml")); err == nil {
fmt.Fprintln(env.Err, "this is a project — checking what is here is called \"convy check\"")
}
return Usage
}
fmt.Fprintln(env.Err, err)
return Usage
}
dir = found
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
rep := check.Suite(s)
if *asJSON {
if code := printJSON(env, rep); code != OK {
return code
}
} else {
printReport(env.Out, rep, s, *quiet)
}
if rep.Errors() > 0 {
return Failed
}
return OK
}
// printJSON writes the findings for a machine. It is the same report the person
// gets, in the same order — a second answer that disagreed with the first would
// be worse than no second answer.
func printJSON(env Env, rep *check.Report) ExitCode {
body, err := rep.JSON()
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
env.Out.Write(body)
return OK
}
func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
findings := rep.Findings()
printFindings(w, findings)
if quiet {
return
}
if len(findings) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "suite: %s, %s, language version %d (%s)\n",
plural(len(s.Docs), "file"),
plural(len(s.Manifest.LiveTopics()), "topic"),
s.Manifest.Language.Version, s.Manifest.Language.Lang)
// A check that was skipped says so. Reaching the language costs a fetch and
// this check runs on every edit, so the documents about the language go
// unread — and a check silently not run reads exactly like a check passed.
if spec := s.Manifest.Language.Source; spec != "" {
fmt.Fprintf(w, "the language lies at %s and was not fetched: its documents went unchecked\n", spec)
}
switch {
case rep.Errors() > 0:
fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings())
case rep.Warnings() > 0:
fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
default:
fmt.Fprintln(w, "suite integrity holds")
}
}
// plural agrees a noun with a count: 1 file, 4 files.
func plural(n int, noun string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, noun)
}
return fmt.Sprintf("%d %ss", n, noun)
}
// printFindings lays the findings out grouped by file. Both checks print them
// the same way: a finding of the suite and a finding of a copy are read by the
// same person, and two layouts would be two things to learn.
func printFindings(w io.Writer, findings []check.Finding) {
current := ""
for _, f := range findings {
if f.Path != current {
if current != "" {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%s\n", f.Path)
current = f.Path
}
where := ""
if f.Line > 0 {
where = fmt.Sprintf(":%d", f.Line)
}
fmt.Fprintf(w, " %s%s %s [%s]\n", f.Severity, where, f.Msg, f.Family)
}
}
+165
View File
@@ -0,0 +1,165 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// A suite starts as a manifest holding only what it knows about itself, and a
// README next to it holding everything a person needs to know to fill it in.
//
// The two are split because the manifest is data the tool rewrites on every
// `suite add` and every `suite retire`, while the README is prose nothing
// touches. Keeping the explanations inside the manifest would mean losing them
// the first time a command wrote the file.
//
// The README is a starting point, in the tool's own language, for its author to
// replace. What the suite itself is about, only its author knows.
const readmeSkeleton = `# Conventions suite
The rules live in ` + "`conventions/`" + `. What each file is and what it may do is
settled by the conventions language; this README is the place to say what this
particular suite is for and how it is kept.
## The manifest
` + "`.conventions-suite.toml`" + ` holds the identity of the suite: the language its rules are
written in, its topics and its rule prefixes. It is written by ` + "`convy`" + ` and
carries no comments — a command rewrites the whole file, and a comment would not
survive that. Explanations belong here instead.
## Topics
A topic is a set of rules about one focus of development — time, configuration,
the database schema — and it is the unit of subscription: a consumer takes it
whole. A name is never renamed and never reissued, because it lives on in
foreign repositories: in the ` + "`origin:`" + ` header of every copy and in the
subscription of every consumer.
Since the name is permanent, a topic is named after a decision and whom it
addresses rather than after the role some part of today's project plays.
## Prefixes
A prefix is four uppercase Latin letters, unique across the suite, chosen for a
file rather than derived by a formula. Rules are addressed by identifier —
` + "`KEYS-5`" + ` — with no path, so the identifier survives a file moving between
axes. A prefix is never reissued either.
The letter X in first position is reserved for the local rules of consuming
repositories. The suite never takes it, so a local prefix can never collide with
a future one here.
## Retirement
Nothing leaves the manifest. A topic or a prefix that is done moves to the
retired half together with a reason and a date, so that the name can never be
handed to something else.
`
func runSuiteInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite init", flag.ContinueOnError)
fs.SetOutput(env.Err)
path := fs.String("path", "", "directory of the suite; it is created if absent")
code := fs.String("lang", "", "code of the natural language the suite is written in")
version := fs.Int("language-version", 1, "version of the conventions language")
if err := fs.Parse(args); err != nil {
return Usage
}
fields := []Field{{
Flag: "path",
Ask: "Directory of the suite",
Hint: "Where the suite will live. The directory is created if it is not there yet.",
Default: ".",
}, {
Flag: "lang",
Ask: "Natural language of the suite",
Hint: "The language the rules are written in. It settles the key words: ДОЛЖЕН and ПОЧЕМУ for ru, MUST and WHY for en.",
Default: manifest.DefaultLanguageCode,
Check: func(v string) error {
_, err := lang.Lookup(*version, v)
return err
},
}}
given := map[string]string{"path": *path, "lang": *code}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite init without arguments asks questions, and there is no terminal to ask on; pass --path and --lang")
return Usage
}
if err := askAll(env, fields, given); err != nil {
return Usage
}
} else if err := resolve(fields, given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
root := given["path"]
if _, err := os.Stat(filepath.Join(root, manifest.Name)); err == nil {
fmt.Fprintf(env.Err, "%s already holds %s: this is a suite already\n", root, manifest.Name)
return Usage
}
if err := os.MkdirAll(filepath.Join(root, "conventions"), 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
name := filepath.Join(root, manifest.Name)
m := &manifest.Manifest{
Language: manifest.Language{Version: *version, Lang: given["lang"]},
Path: name,
}
if err := m.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions"))
// The README is written only when there is none: it is a starting point,
// and a starting point that overwrites what somebody already wrote is not
// one.
readme := filepath.Join(root, "README.md")
if !exists(readme) {
if err := os.WriteFile(readme, []byte(readmeSkeleton), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "created %s\n", readme)
}
fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version)
fmt.Fprint(env.Out, `
next:
rewrite README.md — it says what a topic and a prefix are, not what this suite is for
write the two documents about the language and name them in [language]
convy suite add add the first convention
convy suite check verify the suite holds together
`)
return OK
}
// askAll walks the fields in interactive mode, keeping answers already given on
// the command line.
func askAll(env Env, fields []Field, given map[string]string) error {
d := newDialogue(env)
for _, f := range fields {
if given[f.Flag] != "" {
continue
}
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"flag"
"fmt"
"io"
"sort"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// suite list answers two questions. Bare, it says what the suite holds: topics,
// their layers, the prefixes taken. Given an axis, it says what a component
// would take — which is the read-only half of assembly, and the reason to build
// it before anything starts writing copies into other repositories.
func runSuiteList(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite list", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
topic := fs.String("topic", "", "show one topic only")
langAxis := fs.String("lang", "", "language of the component, to show what it would take")
stackAxis := fs.String("stack", "", "stack of the component, comma-separated, to show what it would take")
retired := fs.Bool("retired", false, "show the retired names instead of the live ones")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if *retired {
listRetired(env.Out, s)
return OK
}
topics := s.Manifest.LiveTopics()
if *topic != "" {
if !s.Manifest.TopicLive(*topic) {
fmt.Fprintf(env.Err, "the suite declares no live topic %q\n", *topic)
return Usage
}
topics = []string{*topic}
}
// The path column is sized to the suite rather than guessed: a name that
// overruns a fixed width breaks every row below it.
width := 0
for _, d := range s.Docs {
width = max(width, len(d.Path))
}
selecting := *langAxis != "" || *stackAxis != ""
component := suite.Component{Lang: split(*langAxis), Stack: split(*stackAxis)}
for i, name := range topics {
if i > 0 {
fmt.Fprintln(env.Out)
}
listTopic(env.Out, s, name, component, selecting, width)
}
if !selecting {
fmt.Fprintf(env.Out, "\n%s, %s, language version %d (%s)\n",
plural(len(topics), "topic"), plural(len(s.Docs), "file"),
s.Manifest.Language.Version, s.Manifest.Language.Lang)
}
return OK
}
func listTopic(w io.Writer, s *suite.Suite, name string, c suite.Component, selecting bool, width int) {
fmt.Fprintf(w, "%s — %s\n", name, s.Manifest.Topics.Live[name])
if !selecting {
for _, d := range s.Layers(name) {
fmt.Fprintln(w, " "+layerLine(s, d, width))
}
return
}
taken, left := s.Assemble(name, c)
if len(taken) == 0 {
fmt.Fprintln(w, " nothing: the topic has no layer this component takes")
}
for _, d := range taken {
fmt.Fprintln(w, " "+layerLine(s, d, width))
}
for _, d := range left {
fmt.Fprintln(w, " · "+layerLine(s, d, width)+" left out")
}
}
// layerLine describes one layer: its prefix, where it lies, what axis it is on
// and how much of it there is.
func layerLine(s *suite.Suite, d *doc.Document, width int) string {
rules, retired := 0, 0
for _, r := range d.Rules {
rules++
if _, ok := r.Block(lang.Retired); ok {
retired++
}
}
count := plural(rules, "rule")
if retired > 0 {
count += fmt.Sprintf(", %d retired", retired)
}
return fmt.Sprintf("%-6s %-*s %-22s %s", s.Prefix(d), width, d.Path, suite.Axis(d), count)
}
func listRetired(w io.Writer, s *suite.Suite) {
sections := []struct {
title string
entries map[string]string
}{
{"topics", s.Manifest.Topics.Retired},
{"prefixes", s.Manifest.Prefixes.Retired},
}
empty := true
for _, section := range sections {
if len(section.entries) == 0 {
continue
}
empty = false
fmt.Fprintf(w, "%s\n", section.title)
for _, key := range sortedKeys(section.entries) {
fmt.Fprintf(w, " %-16s %s\n", key, section.entries[key])
}
}
if empty {
fmt.Fprintln(w, "nothing has been retired yet")
return
}
fmt.Fprintln(w, "\nnone of these names is ever handed out again")
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+367
View File
@@ -0,0 +1,367 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Retirement is the one operation in the model whose mistakes cannot be undone.
// A text rewritten badly gets rewritten again; a prefix handed out twice is a
// reference from a foreign repository that now points at a different statement,
// and that is discovered by its content rather than by any check.
//
// So the command does the bookkeeping the discipline otherwise leans on
// attention for: it writes the date, it words the stub out of the suite's own
// vocabulary, it moves the name into the retired half instead of deleting it,
// and it refuses whatever would leave a reference pointing at nothing.
var ruleIDRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)$`)
func runSuiteRetire(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite retire", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
rule := fs.String("rule", "", "identifier of a rule to retire, PREFIX-N")
prefix := fs.String("prefix", "", "prefix of a convention to retire together with its file")
topic := fs.String("topic", "", "name of a topic to retire")
reason := fs.String("reason", "", "why it is being retired")
date := fs.String("date", "", "date of retirement; today by default")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
when := *date
if when == "" {
when = time.Now().Format("2006-01-02")
}
given := map[string]string{"rule": *rule, "prefix": *prefix, "topic": *topic, "reason": *reason}
talk := newDialogue(env)
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite retire without arguments asks questions, and there is no terminal to ask on; pass one of --rule, --prefix, --topic together with --reason")
return Usage
}
if err := askRetire(env, talk, s, given); err != nil {
return Usage
}
} else if err := resolve(retireFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
targets := 0
for _, key := range []string{"rule", "prefix", "topic"} {
if given[key] != "" {
targets++
}
}
if targets != 1 {
fmt.Fprintln(env.Err, "name exactly one of --rule, --prefix, --topic: the three retire different things and undo differently")
return Usage
}
// The dialogue is built once and carried through: a second one over the
// same input would find it drained, because the first reads ahead.
var d *dialogue
if env.Interactive {
d = talk
}
switch {
case given["rule"] != "":
return retireRule(env, d, s, given, when)
case given["prefix"] != "":
return retirePrefix(env, d, s, given, when)
default:
return retireTopic(env, d, s, given, when)
}
}
func retireFields(s *suite.Suite) []Field {
return []Field{{
Flag: "rule",
Ask: "Rule",
Optional: true,
Check: func(v string) error {
if !ruleIDRe.MatchString(v) {
return fmt.Errorf("a rule is named by its identifier, PREFIX-N")
}
return nil
},
}, {
Flag: "prefix",
Ask: "Prefix",
Optional: true,
Check: func(v string) error {
if _, ok := s.Manifest.PathOf(v); !ok {
return fmt.Errorf("the suite declares no live prefix %s", v)
}
return nil
},
}, {
Flag: "topic",
Ask: "Topic",
Optional: true,
Check: func(v string) error {
if !s.Manifest.TopicLive(v) {
return fmt.Errorf("the suite declares no live topic %q", v)
}
return nil
},
}, {
Flag: "reason",
Ask: "Reason",
Hint: "Why it is going. The reason outlives the thing itself — it is what a reader finds in place of what they were looking for.",
}}
}
// askRetire walks the dialogue: what kind of thing, which one, and why.
func askRetire(env Env, d *dialogue, s *suite.Suite, given map[string]string) error {
fields := retireFields(s)
kind, err := d.ask(Field{
Ask: "What is being retired",
Hint: "A rule keeps its number and turns into a stub; a convention takes its file with it; a topic goes once no layer of it is left.",
Options: []string{"rule", "convention", "topic"},
Default: "rule",
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
flagOf := map[string]string{"rule": "rule", "convention": "prefix", "topic": "topic"}[kind]
for _, f := range fields {
if f.Flag != flagOf && f.Flag != "reason" {
continue
}
f.Optional = false
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}
// retireRule replaces the norm and the rationale with a stub, keeping the
// heading and the number. The number stays taken forever, so a reference from a
// foreign repository lands on the explanation instead of on nothing.
func retireRule(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
m := ruleIDRe.FindStringSubmatch(given["rule"])
num, _ := strconv.Atoi(m[2])
target, ok := s.ByPrefix[m[1]]
if !ok {
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", m[1])
return Usage
}
var found *doc.Rule
for i := range target.Rules {
if target.Rules[i].Num == num {
found = &target.Rules[i]
}
}
if found == nil {
fmt.Fprintf(env.Err, "%s holds no rule numbered %d\n", target.Path, num)
return Usage
}
if _, already := found.Block(lang.Retired); already {
fmt.Fprintf(env.Err, "%s is retired already\n", found.ID())
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"%s in %s loses its norm and its rationale, keeping the number. Go on?", found.ID(), target.Path))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
name := filepath.Join(s.Root, filepath.FromSlash(target.Path))
body, err := os.ReadFile(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
lines := strings.Split(string(body), "\n")
stub := fmt.Sprintf("**%s %s.** %s", s.Vocab.MarkWord(lang.Retired), when, given["reason"])
replacement := append([]string{""}, wrap(stub, 78)...)
replacement = append(replacement, "")
out := make([]string, 0, len(lines))
out = append(out, lines[:found.Line]...)
out = append(out, replacement...)
if found.End < len(lines) {
out = append(out, lines[found.End:]...)
}
if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s is retired in %s\n", found.ID(), target.Path)
fmt.Fprintf(env.Out, "the number stays taken, and a reference to it now lands on the reason\n")
return OK
}
// retirePrefix retires a convention together with its file. The rules of that
// file cease to exist, so the command refuses while anything still points at
// them: a stub can stand in for a rule, but nothing stands in for a file.
func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
prefix := given["prefix"]
target, ok := s.ByPrefix[prefix]
if !ok {
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", prefix)
return Usage
}
if pointing := referencesTo(s, prefix, target); len(pointing) > 0 {
fmt.Fprintf(env.Err, "%s is still pointed at, so retiring it would leave references resolving to nothing:\n", prefix)
for _, where := range pointing {
fmt.Fprintf(env.Err, " %s\n", where)
}
fmt.Fprintln(env.Err, "move or retire those rules first")
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"%s goes, and %s is deleted. The prefix is never reissued. Go on?", target.Path, target.Path))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
s.Manifest.Prefixes.Retire(prefix, note)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.Remove(filepath.Join(s.Root, filepath.FromSlash(target.Path))); err != nil {
fmt.Fprintf(env.Err, "the manifest was updated, but the file was not removed: %s\n", err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s is retired and %s is gone\n", prefix, target.Path)
if left := s.Layers(target.Front.Topic); len(left) == 1 {
fmt.Fprintf(env.Out, "the topic %q has no layer left; retire the topic too if it is done\n", target.Front.Topic)
}
return OK
}
// retireTopic sends a topic name to the retired half. A topic lives as long as
// one layer of it does, so the command refuses while any layer is left.
func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
topic := given["topic"]
if layers := s.Layers(topic); len(layers) > 0 {
fmt.Fprintf(env.Err, "the topic %q still holds layers, and a topic lives as long as one of them does:\n", topic)
for _, d := range layers {
fmt.Fprintf(env.Err, " %s\n", d.Path)
}
fmt.Fprintln(env.Err, "retire those conventions first")
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"the name %q goes to the retired half and is never handed to another topic. Go on?", topic))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
s.Manifest.Topics.Retire(topic, when+": "+given["reason"])
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\nthe topic %q is retired\n", topic)
return OK
}
// referencesTo finds where the rules of a prefix are pointed at from outside
// the file that owns them.
func referencesTo(s *suite.Suite, prefix string, own *doc.Document) []string {
var out []string
for _, d := range s.Docs {
if d == own {
continue
}
for _, ref := range refsOf(d, prefix) {
out = append(out, fmt.Sprintf("%s:%d %s", d.Path, ref.line, ref.text))
}
}
return out
}
type refAt struct {
line int
text string
}
var anyRefRe = regexp.MustCompile(`\b[A-Z]{4}-\d+(?:\.\d+)?`)
func refsOf(d *doc.Document, prefix string) []refAt {
var out []refAt
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, text := range anyRefRe.FindAllString(doc.StripInline(d.Line(n)), -1) {
if strings.HasPrefix(text, prefix+"-") {
out = append(out, refAt{line: n, text: text})
}
}
}
return out
}
// wrap breaks a paragraph at a width, the way the text around it is written by
// hand. A stub that runs off in one long line reads as machine-made and gets
// reflowed by the next person to touch the file.
func wrap(text string, width int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{""}
}
lines := []string{words[0]}
for _, w := range words[1:] {
last := len(lines) - 1
if len([]rune(lines[last]))+1+len([]rune(w)) <= width {
lines[last] += " " + w
continue
}
lines = append(lines, w)
}
return lines
}
+224
View File
@@ -0,0 +1,224 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Adding a rule is where the tool stops merely judging the form and starts
// holding it. Everything the check verifies afterwards is known here already:
// the next free number, the prefix of the file, the words of the modality and
// the marks, the order of the blocks. Leaving the author to reproduce all of
// that by hand and then reporting what they got wrong is the worse half of the
// deal.
func runSuiteRule(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite rule", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
prefix := fs.String("prefix", "", "prefix of the convention the rule joins")
title := fs.String("title", "", "heading of the rule, what it is about")
modality := fs.String("modality", "", "step of the scale: "+strings.Join(lang.LevelNames(), ", "))
norm := fs.String("norm", "", "the norm itself, one statement")
why := fs.String("why", "", "what breaks if it is done otherwise")
examples := fs.String("examples", "", "illustration of the norm; optional")
after := fs.String("after", "", "identifier of the rule to place this one after; the last by default")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
given := map[string]string{
"prefix": *prefix, "title": *title, "modality": *modality,
"norm": *norm, "why": *why, "examples": *examples, "after": *after,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite rule without arguments asks questions, and there is no terminal to ask on; pass --prefix, --title, --modality, --norm and --why")
return Usage
}
if err := askAll(env, ruleFields(s), given); err != nil {
return Usage
}
} else if err := resolve(ruleFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
return writeRule(env, s, given)
}
func ruleFields(s *suite.Suite) []Field {
return []Field{{
Flag: "prefix",
Ask: "Prefix of the convention",
Hint: "Which file the rule joins. The number is taken from that file: the next one after its highest, never a number that was used before.",
Check: func(v string) error {
if _, ok := s.ByPrefix[v]; !ok {
return fmt.Errorf("the suite declares no live prefix %s", v)
}
return nil
},
}, {
Flag: "title",
Ask: "Heading of the rule",
Hint: "What the rule is about, in one line. It is read on its own in a list, so it says the substance rather than the topic.",
}, {
Flag: "modality",
Ask: "Step of the scale",
Hint: "How binding it is. The highest step asks two things at once: a named harm from breaking it, and a verdict two reviewers reach alike. Neither one — the rule belongs a step lower.",
Options: lang.LevelNames(),
Default: "recommendation",
Check: func(v string) error {
if _, ok := lang.LevelByName(v); ok {
return nil
}
if _, ok := s.Vocab.Modal(v); ok {
return nil
}
return fmt.Errorf("one of: %s — or the word the suite uses for the step",
strings.Join(lang.LevelNames(), ", "))
},
}, {
Flag: "norm",
Ask: "The norm",
Hint: "One statement of what is required. If it does not fit in one, these are two rules: half a compound norm cannot be addressed on its own.",
}, {
Flag: "why",
Ask: "The rationale",
Hint: "What breaks if it is done otherwise — not the norm said again. A cause that cannot be put into words means this is a habit rather than a rule.",
}, {
Flag: "examples",
Ask: "Examples",
Hint: "Code showing the norm at work, usually bad against good. Worth it where showing is cheaper than saying; a rule about choosing a boundary has nothing to illustrate.",
Optional: true,
}, {
Flag: "after",
Ask: "Place after",
Hint: "Identifier of the rule this one follows. Order in a file goes by reading rather than by number, so a rule elaborating another belongs next to it. Empty puts it last.",
Optional: true,
Check: func(v string) error {
if !ruleIDRe.MatchString(v) {
return fmt.Errorf("a rule is named by its identifier, PREFIX-N")
}
return nil
},
}}
}
func writeRule(env Env, s *suite.Suite, given map[string]string) ExitCode {
prefix := given["prefix"]
target := s.ByPrefix[prefix]
level, ok := lang.LevelByName(given["modality"])
if !ok {
if l, isWord := s.Vocab.Modal(given["modality"]); isWord {
level = l
} else {
fmt.Fprintf(env.Err, "unknown step %q; the steps are: %s\n",
given["modality"], strings.Join(lang.LevelNames(), ", "))
return Usage
}
}
own := ownRules(target, prefix)
if len(own) == 0 {
fmt.Fprintf(env.Err, "%s holds no rule yet, and a number is taken from the highest one; write the first rule as ### %s-1 by hand\n", target.Path, prefix)
return Usage
}
num := 0
for _, r := range own {
num = max(num, r.Num)
}
num++
// A number is never reused, so a rule retired long ago still owns its own.
// Taking the next after the highest is the only choice that cannot collide
// with a reference living in a foreign repository.
place := own[len(own)-1]
if given["after"] != "" {
m := ruleIDRe.FindStringSubmatch(given["after"])
if m[1] != prefix {
fmt.Fprintf(env.Err, "--after names %s, which belongs to another file than %s\n", given["after"], prefix)
return Usage
}
found := false
for _, r := range own {
if strconv.Itoa(r.Num) == m[2] {
place, found = r, true
}
}
if !found {
fmt.Fprintf(env.Err, "%s holds no rule %s\n", target.Path, given["after"])
return Usage
}
}
name := filepath.Join(s.Root, filepath.FromSlash(target.Path))
body, err := os.ReadFile(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
lines := strings.Split(string(body), "\n")
at := place.End
for at > place.Line && strings.TrimSpace(lines[at-1]) == "" {
at--
}
block := renderRule(s, prefix, num, level, given)
out := make([]string, 0, len(lines)+len(block))
out = append(out, lines[:at]...)
out = append(out, block...)
out = append(out, lines[at:]...)
if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s-%d added to %s after %s\n", prefix, num, target.Path, place.ID())
return OK
}
// renderRule lays out the blocks in the order the language fixes: the norm,
// then the reason, then the illustration.
func renderRule(s *suite.Suite, prefix string, num int, level lang.Level, given map[string]string) []string {
out := []string{"", fmt.Sprintf("### %s-%d. %s", prefix, num, given["title"]), ""}
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.Word(level), given["norm"]), 78)...)
out = append(out, "")
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.MarkWord(lang.Rationale), given["why"]), 78)...)
if given["examples"] != "" {
out = append(out, "")
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.MarkWord(lang.Examples), given["examples"]), 78)...)
}
return out
}
// ownRules picks the rules a file numbers itself, in the order they stand.
func ownRules(d *doc.Document, prefix string) []doc.Rule {
var out []doc.Rule
for _, r := range d.Rules {
if r.Prefix == prefix {
out = append(out, r)
}
}
return out
}
+238
View File
@@ -0,0 +1,238 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy sync makes the files agree with the manifest. The manifest is the
// truth: it says which components exist, where they write and what each takes,
// and everything under those directories follows from that.
//
// It divides from pull by what it is about. pull is about the contents of a
// copy — it takes the text of every subscription afresh, and the diff it leaves
// is the point of running it. sync is about the set of files: what the manifest
// calls for and is not there gets assembled, what is there and nothing calls
// for gets reported and, when nothing of the repository is in it, removed.
//
// A copy carrying a local part is never removed. Below the marker is the one
// thing in the directory that exists nowhere else, and a command that tidies up
// has no business deciding it is spent.
func runSync(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy sync", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to bring in line; every one of them by default")
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
return code
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
names, code := components(env, o.Manifest, *forComponent)
if code != OK {
return code
}
problems := validateManifest(o, names)
for _, p := range problems {
fmt.Fprintf(env.Err, "%s\n", p)
}
if len(problems) > 0 {
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
return Failed
}
changed, left := 0, 0
for i, name := range names {
c := o.Manifest.Components[name]
if i > 0 {
fmt.Fprintln(env.Out)
}
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
n, stuck, code := syncComponent(env, o, c, *dry)
if code != OK {
return code
}
changed += n
left += stuck
}
fmt.Fprintln(env.Out)
switch {
case *dry && changed > 0:
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
case changed > 0:
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
case left == 0:
fmt.Fprintln(env.Out, "the layout already follows the manifest")
}
if left > 0 {
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
return Failed
}
return OK
}
// validate checks the manifest against itself and against the suite. Everything
// wrong is reported at once: being sent back one line at a time is the worst way
// to learn what a file wants.
func validateManifest(o *opened, names []string) []string {
var out []string
if err := distinctDirs(o.Manifest); err != nil {
out = append(out, err.Error())
}
for _, name := range names {
c := o.Manifest.Components[name]
if c.Dir == "" {
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
}
if len(c.Lang) > 1 {
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
name, strings.Join(c.Lang, ", ")))
}
seen := make(map[string]bool, len(c.Topics))
for _, topic := range c.Topics {
switch {
case seen[topic]:
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
case o.Suite.Manifest.TopicRetired(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
case !o.Suite.Manifest.TopicLive(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
default:
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
}
}
seen[topic] = true
}
}
return out
}
// syncComponent brings one directory in line. It returns how many files moved
// and how many it would not touch.
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
for _, topic := range c.Topics {
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
continue
}
changed++
if dry {
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
continue
}
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
}
orphans, err := orphaned(o.Root, c)
if err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
for _, orphan := range orphans {
switch {
case orphan.local:
left++
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
orphan.path, orphan.topic)
case dry:
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
default:
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
}
}
if !dry {
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
if err != nil {
fmt.Fprintf(env.Err, " %s\n", err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
}
reportDangling(env, o, c)
return changed, left, OK
}
// reportDangling says which copies point at topics this component did not take.
// It is a note rather than a finding: naming a rule of another topic outside
// the norm is allowed, and the reader loses a pointer rather than a
// requirement. But it is also the one thing about a copy that cannot be seen
// without the suite, so it is said where the suite is at hand.
func reportDangling(env Env, o *opened, c manifest.Component) {
docs, _ := copies(o.Root, c.Dir)
for _, d := range docs {
refs := check.Dangling(d, o.Suite, c.Subscribed)
if len(refs) == 0 {
continue
}
var parts []string
for _, ref := range refs {
parts = append(parts, fmt.Sprintf("%s (%s)", ref.Text, check.TopicOf(o.Suite, ref.Prefix)))
}
fmt.Fprintf(env.Out, " ? %-28s points at %s — not taken by this component\n",
d.Path, strings.Join(parts, ", "))
}
}
// orphan is a copy in a component directory that the manifest does not call for.
type orphan struct {
path string
topic string
local bool
}
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
// the origin key: README.md belongs to the repository, READING.md belongs to the
// suite, and a file whose origin was taken away has become a document of the
// repository — none of the three is anyone's to remove.
func orphaned(root string, c manifest.Component) ([]orphan, error) {
docs, broken := copies(root, c.Dir)
if len(broken) > 0 {
return nil, broken[0]
}
var out []orphan
for _, d := range docs {
if c.Subscribed(d.Front.Origin) {
continue
}
local := ""
if at := d.Marker(); at > 0 {
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
}
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
}
return out, nil
}
+271
View File
@@ -0,0 +1,271 @@
package cli_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// subscribe edits the project manifest the way a person would: by hand, in the
// file. That is the whole premise of sync — the manifest is the truth, and the
// layout follows it.
func subscribe(t *testing.T, root string, topics ...string) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
c := m.Components["backend"]
c.Topics = topics
m.Components["backend"] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
func TestSyncAssemblesWhatIsMissingAndRemovesWhatIsOrphaned(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
// The manifest is edited by hand: time goes, logging comes.
subscribe(t, root, "logging")
code, out := run(t, root, "", false, "sync", "--dry-run")
if code != cli.OK {
t.Fatalf("the dry run returned %d: %s", code, out)
}
if !strings.Contains(out, "would change") {
t.Errorf("the dry run promised nothing:\n%s", out)
}
if !exists(t, root, "docs/conventions/time.md") {
t.Errorf("the dry run removed a file")
}
if exists(t, root, "docs/conventions/logging.md") {
t.Errorf("the dry run assembled a file")
}
code, out = run(t, root, "", false, "sync")
if code != cli.OK {
t.Fatalf("sync returned %d: %s", code, out)
}
if !exists(t, root, "docs/conventions/logging.md") {
t.Errorf("the subscribed topic was not assembled:\n%s", out)
}
if exists(t, root, "docs/conventions/time.md") {
t.Errorf("the copy nothing subscribes to stayed:\n%s", out)
}
// Run again: nothing left to do, and it says so.
code, out = run(t, root, "", false, "sync")
if code != cli.OK || !strings.Contains(out, "already follows the manifest") {
t.Errorf("a second sync found work to do:\n%s", out)
}
}
// Below the marker is the one thing in the directory that exists nowhere else.
func TestSyncLeavesAnOrphanCarryingALocalPart(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
subscribe(t, root)
code, out := run(t, root, "", false, "sync")
if code == cli.OK {
t.Fatalf("an orphan with a local part went unremarked:\n%s", out)
}
if !strings.Contains(out, "local part") {
t.Errorf("the report does not say why the file was left:\n%s", out)
}
if !exists(t, root, "docs/conventions/time.md") {
t.Fatalf("the local part was destroyed:\n%s", out)
}
}
// The manifest is the truth, so a manifest that does not hold together stops
// the command before anything is written.
func TestSyncValidatesTheManifestBeforeTouchingAnything(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
cases := []struct {
name string
change func(*manifest.Project)
want string
}{{
name: "a topic the suite does not declare",
change: func(m *manifest.Project) { subscribeTo(m, "billing") },
want: "no such topic",
}, {
name: "the same topic twice",
change: func(m *manifest.Project) { subscribeTo(m, "time", "time") },
want: "twice",
}, {
name: "two languages in one component",
change: func(m *manifest.Project) {
c := m.Components["backend"]
c.Lang = []string{"go", "javascript"}
m.Components["backend"] = c
},
want: "declares two languages",
}, {
name: "a topic no layer of which fits",
change: func(m *manifest.Project) {
// web-ui lives on the htmx stack only, and this component is on
// no stack at all.
subscribeTo(m, "web-ui")
},
want: "no layer of it fits",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
tc.change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "sync")
if code == cli.OK {
t.Fatalf("the manifest went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the report does not say %q:\n%s", tc.want, out)
}
if !strings.Contains(out, "nothing was touched") {
t.Errorf("the report does not say it wrote nothing:\n%s", out)
}
})
}
}
func subscribeTo(m *manifest.Project, topics ...string) {
c := m.Components["backend"]
c.Topics = topics
m.Components["backend"] = c
}
func exists(t *testing.T, parts ...string) bool {
t.Helper()
_, err := os.Stat(filepath.Join(parts...))
return err == nil
}
// One directory inside another is the same collision told less plainly, and it
// makes every command that walks a component find the copies of the other.
func TestNestedComponentDirectoriesAreRefused(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs", "--lang", "go")
addComponent(t, root, "web", manifest.Component{Dir: "docs/web", Topics: []string{}})
for _, name := range []string{"sync", "pull", "check"} {
t.Run(name, func(t *testing.T) {
code, out := run(t, root, "", false, name)
if code == cli.OK {
t.Fatalf("nested directories went through:\n%s", out)
}
if !strings.Contains(out, "lies inside") {
t.Errorf("the refusal does not say what is nested:\n%s", out)
}
})
}
}
// A copy may name a rule of a topic the component did not take: naming one
// outside the norm is allowed, and the reader loses a pointer rather than a
// requirement. It is still the one thing about a copy that cannot be seen
// without the suite, so sync says it.
func TestSyncNotesAReferenceToAnUnsubscribedTopic(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body += "\nПодробности — SLOG-1.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "sync")
if code != cli.OK {
t.Fatalf("sync returned %d: %s", code, out)
}
if !strings.Contains(out, "SLOG-1") || !strings.Contains(out, "logging") {
t.Errorf("the note does not name the reference and its topic:\n%s", out)
}
// Subscribed, it is no longer dangling.
run(t, root, "", false, "add", "logging")
_, out = run(t, root, "", false, "sync")
if strings.Contains(out, "not taken by this component") {
t.Errorf("the note stayed after the topic was taken:\n%s", out)
}
}
// The findings a person reads and the findings a machine reads are the same
// findings, in the same order.
func TestCheckWritesJSONForACallerThatIsNotAPerson(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
code, out := run(t, root, "", false, "check", "--json")
if code != cli.OK {
t.Fatalf("convy check --json returned %d: %s", code, out)
}
var clean struct {
Findings []map[string]any `json:"findings"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}
if err := json.Unmarshal([]byte(out), &clean); err != nil {
t.Fatalf("the output is not JSON: %v\n%s", err, out)
}
if len(clean.Findings) != 0 || clean.Errors != 0 {
t.Errorf("a sound project reported findings: %s", out)
}
// Now break it and read the finding back.
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body = strings.Replace(body, "<!-- conv:local -->", "", 1)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out = run(t, root, "", false, "check", "--json")
if code == cli.OK {
t.Fatalf("the broken copy passed:\n%s", out)
}
if err := json.Unmarshal([]byte(out), &clean); err != nil {
t.Fatalf("the output is not JSON: %v\n%s", err, out)
}
if clean.Errors != 1 || len(clean.Findings) != 1 {
t.Fatalf("expected one error, got %s", out)
}
f := clean.Findings[0]
for key, want := range map[string]any{"severity": "error", "family": "spread"} {
if f[key] != want {
t.Errorf("%s is %v, expected %v", key, f[key], want)
}
}
if f["path"] != "docs/conventions/time.md" {
t.Errorf("path is %v", f["path"])
}
}
+423
View File
@@ -0,0 +1,423 @@
// Package doc parses a file written in the conventions language: its front
// matter, its rules and their blocks.
//
// The parsing model is taken straight from the language. A rule is a heading of
// the form `### <PREFIX>-<number>. <title>`; the area of a rule runs from that
// heading to the next heading of any level. Inside the area text belongs to the
// last block opened: a mark opens a block, and the block lasts until the next
// mark or until the end of the area. Prose is what lies outside rule areas.
//
// The boundary is counted by the markup rather than by a judgement about where
// a rule ended: that is exactly what makes the "no modal words outside rules"
// check implementable at all.
package doc
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/lang"
)
// Heading is a heading of any level.
type Heading struct {
Level int
Text string
Line int
}
// BlockKind tells a norm block from a marked one.
type BlockKind int
const (
// Norm is a block of the norm, opened by a modal word.
Norm BlockKind = iota + 1
// Marked is a block under a mark: rationale, examples, retired,
// mechanized.
Marked
)
// Block is a part of a rule opened by a vocabulary word at the start of a
// paragraph.
type Block struct {
Kind BlockKind
Word string
Level lang.Level
Mark lang.Mark
// Start is the line the opening mark stands on.
Start int
// End is the last line of the block: a block lasts until the next mark
// or until the end of the rule area.
End int
// Rest is the text of the paragraph after the mark.
Rest string
}
// Rule is a rule: a heading carrying an identifier, plus its area.
type Rule struct {
Prefix string
Num int
Title string
// Line is the line of the heading.
Line int
// HeadingLevel is the level of the heading; the canonical form is three.
HeadingLevel int
// Malformed is set when a heading was recognized as a rule but is not
// written in the form `### <PREFIX>-<number>. <title>`.
Malformed string
// Start and End bound the rule area: from the line after the heading to
// the line before the next heading, inclusive.
Start, End int
Blocks []Block
}
// ID returns the identifier of the rule.
func (r Rule) ID() string {
return fmt.Sprintf("%s-%d", r.Prefix, r.Num)
}
// Block finds the first block under the given mark.
func (r Rule) Block(m lang.Mark) (Block, bool) {
for _, b := range r.Blocks {
if b.Kind == Marked && b.Mark == m {
return b, true
}
}
return Block{}, false
}
// Norms lists the norm blocks. There must be exactly zero of them (for a
// retired rule) or one: a norm is a single statement, and two norms under one
// number leave no way to address either on its own.
func (r Rule) Norms() []Block {
var out []Block
for _, b := range r.Blocks {
if b.Kind == Norm {
out = append(out, b)
}
}
return out
}
// Paragraph is a paragraph: the lines between blank ones.
type Paragraph struct {
Start, End int
Lines []string
}
// Text joins the paragraph into a single string.
func (p Paragraph) Text() string {
return strings.Join(p.Lines, " ")
}
// Document is a parsed file.
type Document struct {
// Path is the path from the root of the suite, in slash form.
Path string
Front Front
lines []string
fence []bool
// Body is the first line of the body, past the front matter.
Body int
Headings []Heading
Rules []Rule
}
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
// ruleHeadRe catches a heading that starts with a rule identifier —
// including one written out of form: otherwise a typo in a heading would
// turn a rule into prose and vanish from the numbering unnoticed.
ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`)
fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})")
)
// Load reads and parses a file. path is the path from the root of the suite,
// name the path in the file system.
func Load(path, name string) (*Document, error) {
data, err := os.ReadFile(name)
if err != nil {
return nil, err
}
return Parse(path, string(data))
}
// Parse parses the contents of a file.
func Parse(path, content string) (*Document, error) {
d := &Document{Path: path}
d.lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
front, body, err := parseFront(d.lines)
d.Front = front
d.Body = body
if err != nil {
return d, fmt.Errorf("%s: front matter: %w", path, err)
}
d.markFences()
d.collectHeadings()
d.collectRules()
return d, nil
}
// markFences marks the lines inside fenced code blocks. Everything checked by
// parsing text skips them: a SQL sample with an uppercase WHEN does not make
// the suite bilingual, and a `### XKEY-5` inside a documentation sample is not
// a rule.
func (d *Document) markFences() {
d.fence = make([]bool, len(d.lines))
open := ""
for i, line := range d.lines {
m := fenceRe.FindStringSubmatch(line)
if open == "" {
if m != nil {
open = m[1]
d.fence[i] = true
}
continue
}
d.fence[i] = true
if m != nil && len(m[1]) >= len(open) && m[1][0] == open[0] {
open = ""
}
}
}
func (d *Document) collectHeadings() {
for i, line := range d.lines {
num := i + 1
if num < d.Body || d.fence[i] {
continue
}
m := headingRe.FindStringSubmatch(line)
if m == nil {
continue
}
d.Headings = append(d.Headings, Heading{
Level: len(m[1]),
Text: strings.TrimSpace(m[2]),
Line: num,
})
}
}
func (d *Document) collectRules() {
for i, h := range d.Headings {
m := ruleHeadRe.FindStringSubmatch(h.Text)
if m == nil {
continue
}
num, err := strconv.Atoi(m[2])
if err != nil {
continue
}
rule := Rule{
Prefix: m[1],
Num: num,
Line: h.Line,
HeadingLevel: h.Level,
Start: h.Line + 1,
End: d.Len(),
}
if i+1 < len(d.Headings) {
rule.End = d.Headings[i+1].Line - 1
}
tail := m[3]
switch {
case strings.HasPrefix(tail, ". "):
rule.Title = strings.TrimSpace(tail[2:])
case tail == "":
rule.Malformed = "the rule heading has no title"
case strings.HasPrefix(tail, "."):
rule.Title = strings.TrimSpace(tail[1:])
default:
rule.Malformed = "no period after the identifier in the heading"
rule.Title = strings.TrimSpace(tail)
}
d.Rules = append(d.Rules, rule)
}
}
// Blocks marks up the rule areas using the suite's vocabulary. The markup is
// deferred until the manifest is loaded: before that it is unknown which
// vocabulary the suite is written in.
func (d *Document) Blocks(v lang.Vocabulary) {
for i := range d.Rules {
r := &d.Rules[i]
r.Blocks = nil
for _, p := range d.Paragraphs(r.Start, r.End) {
word, rest, ok := boldLead(p.Lines[0])
if !ok {
continue
}
w, ok := v.Lead(word)
if !ok {
continue
}
b := Block{Word: w, Start: p.Start, End: r.End, Rest: strings.TrimSpace(rest)}
if level, ok := v.Modal(w); ok {
b.Kind, b.Level = Norm, level
} else {
mark, _ := v.Mark(w)
b.Kind, b.Mark = Marked, mark
}
if n := len(r.Blocks); n > 0 {
r.Blocks[n-1].End = p.Start - 1
}
r.Blocks = append(r.Blocks, b)
}
}
}
// boldLead extracts the contents of the first bold span if the paragraph opens
// with one. A mark stands first in its paragraph, in bold and with a period —
// that is exactly what tells it from a mention of a step mid-sentence.
func boldLead(line string) (bold, rest string, ok bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "**") {
return "", "", false
}
end := strings.Index(line[2:], "**")
if end < 0 {
return "", "", false
}
return line[2 : 2+end], line[2+end+2:], true
}
// Paragraphs cuts a range of lines into paragraphs. Bounds are inclusive and
// numbering starts at one. Lines inside fenced blocks do not enter paragraphs:
// code is an illustration, not the text of a rule.
func (d *Document) Paragraphs(from, to int) []Paragraph {
var out []Paragraph
var cur *Paragraph
for n := max(from, 1); n <= min(to, d.Len()); n++ {
line := d.lines[n-1]
if d.fence[n-1] || strings.TrimSpace(line) == "" {
cur = nil
continue
}
if cur == nil {
out = append(out, Paragraph{Start: n, End: n})
cur = &out[len(out)-1]
}
cur.Lines = append(cur.Lines, line)
cur.End = n
}
return out
}
// Preamble returns the bounds of the introductory prose: from the body to the
// first rule.
func (d *Document) Preamble() (from, to int) {
if len(d.Rules) == 0 {
return d.Body, d.Len()
}
return d.Body, d.Rules[0].Line - 1
}
// InRule reports whether a line lies inside the area of some rule.
func (d *Document) InRule(n int) bool {
for _, r := range d.Rules {
if n >= r.Line && n <= r.End {
return true
}
}
return false
}
// Line returns line number n.
func (d *Document) Line(n int) string {
if n < 1 || n > d.Len() {
return ""
}
return d.lines[n-1]
}
// Fenced reports whether a line lies inside a fenced code block.
func (d *Document) Fenced(n int) bool {
return n >= 1 && n <= d.Len() && d.fence[n-1]
}
// Prose walks the lines of the body that did not land in fenced blocks and
// hands them over with the contents of inline code cut out. Inside backticks an
// identifier stands as a sample of the notation rather than as a reference —
// and it is the markup that tells the two apart.
func (d *Document) Prose(yield func(n int, text string) bool) {
for n := d.Body; n <= d.Len(); n++ {
if d.fence[n-1] {
continue
}
if !yield(n, StripInline(d.lines[n-1])) {
return
}
}
}
// StripInline cuts out the contents of inline code, keeping the delimiters.
func StripInline(line string) string {
var b strings.Builder
inCode := false
for _, r := range line {
if r == '`' {
inCode = !inCode
b.WriteRune(' ')
continue
}
if inCode {
b.WriteRune(' ')
continue
}
b.WriteRune(r)
}
return b.String()
}
// Len returns the number of lines in the file.
func (d *Document) Len() int { return len(d.lines) }
// LocalMarker is the boundary inside a copy between what the suite wrote and
// what the consuming repository wrote. It is the one piece of markup inside a
// convention that means something to the tool, and it is single and nameless,
// so there is no name to be orphaned by a rename.
//
// It lives here, next to the parsing, because both halves of the tool need it
// and need it read the same way: the assembler to know what to keep, the check
// to know what answers to which rules. Two constants would drift in silence.
const LocalMarker = "<!-- conv:local -->"
// Marker returns the line the local marker stands on, or zero.
//
// A marker inside a fenced block is a quotation of the markup rather than the
// markup itself — a convention about keeping copies would carry one — and
// taking it for the boundary would hand the whole document to the repository.
func (d *Document) Marker() int {
for n := 1; n <= d.Len(); n++ {
if !d.fence[n-1] && strings.TrimSpace(d.lines[n-1]) == LocalMarker {
return n
}
}
return 0
}
// Markers counts the local markers outside fenced blocks.
func (d *Document) Markers() []int {
var out []int
for n := 1; n <= d.Len(); n++ {
if !d.fence[n-1] && strings.TrimSpace(d.lines[n-1]) == LocalMarker {
out = append(out, n)
}
}
return out
}
// Below returns the lines from n to the end, joined.
func (d *Document) Below(n int) string {
if n < 1 || n > d.Len() {
return ""
}
return strings.Join(d.lines[n-1:], "\n")
}
+90
View File
@@ -0,0 +1,90 @@
package doc
import (
"fmt"
"strings"
)
// Front is the front matter of a file. There are few keys and all of them are
// flat, so the parsing is done here: pulling in YAML for four `key: value`
// lines earns nothing.
//
// The axis of a layer is declared here by the lang and stack keys rather than
// derived from the path (META-38); the absence of both means the base layer of
// the topic.
type Front struct {
Topic string
Prefix string
Lang string
Stack string
Extends string
// Origin is the key a copy carries in a consuming repository: the name of
// the topic it was assembled from. It is the whole front matter of a copy
// and it appears nowhere in a suite — a file that lost it is no longer a
// copy and is never overwritten again.
Origin string
// At is the line a key was declared on, so a finding can point at the
// declaration rather than at the top of the file.
At map[string]int
// Unknown lists keys the model does not know.
Unknown []string
// End is the line of the closing delimiter. The body of the file starts
// on the next one.
End int
// Present says whether there was any front matter at all.
Present bool
}
// Axis reports whether the layer declares an axis. A layer without one is the
// base layer.
func (f Front) Axis() bool {
return f.Lang != "" || f.Stack != ""
}
// parseFront parses the front matter out of the file's lines. It returns the
// front matter and the number of the first line of the body.
func parseFront(lines []string) (Front, int, error) {
front := Front{At: make(map[string]int)}
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return front, 1, nil
}
front.Present = true
for i := 1; i < len(lines); i++ {
line := lines[i]
num := i + 1
if strings.TrimSpace(line) == "---" {
front.End = num
return front, num + 1, nil
}
if strings.TrimSpace(line) == "" {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
return front, num, fmt.Errorf("front matter line %d is not of the form \"key: value\"", num)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
front.At[key] = num
switch key {
case "topic":
front.Topic = value
case "prefix":
front.Prefix = value
case "lang":
front.Lang = value
case "stack":
front.Stack = value
case "extends":
front.Extends = value
case "origin":
front.Origin = value
default:
front.Unknown = append(front.Unknown, key)
}
}
return front, len(lines) + 1, fmt.Errorf("front matter is not closed by a --- delimiter")
}
+434
View File
@@ -0,0 +1,434 @@
// Package lang holds the vocabularies of the conventions language: the words
// that spell out a rule's modality, the marks of its blocks, and the
// connectives of a scenario block.
//
// A vocabulary belongs to a language version and to the suite's natural
// language, not to the suite itself: version 1 in Russian names the same words
// in every repository, and repeating that list in every manifest buys nothing.
// While the language specification lives together with the canon, the
// vocabularies live here; once it moves to its own repository with vocabulary
// files of its own, those become the source, and the Vocabulary type together
// with every check built on it stays as it is.
package lang
import (
"fmt"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// Level is a step on the scale of obligation. There are five steps in the four
// categories of ISO/IEC Directives, Part 2; which words name them is a
// parameter of the natural language, while the scale is one for all
// vocabularies.
type Level int
const (
Requirement Level = iota + 1
Prohibition
Recommendation
RecommendationAgainst
Permission
)
// String names the step for diagnostics — the role, not the vocabulary word.
func (l Level) String() string {
switch l {
case Requirement:
return "requirement"
case Prohibition:
return "prohibition"
case Recommendation:
return "recommendation"
case RecommendationAgainst:
return "recommendation against"
case Permission:
return "permission"
}
return "unknown level"
}
// levelNames are the language-neutral handles of the steps: the categories of
// the standard rather than the words of any one natural language. A caller
// naming a step says "requirement" and gets ДОЛЖЕН or MUST depending on the
// suite — which is what keeps an agent out of the business of knowing Russian.
var levelNames = []struct {
name string
level Level
}{
{"requirement", Requirement},
{"prohibition", Prohibition},
{"recommendation", Recommendation},
{"not-recommended", RecommendationAgainst},
{"permission", Permission},
}
// LevelByName resolves the handle of a step.
func LevelByName(name string) (Level, bool) {
for _, n := range levelNames {
if n.name == name {
return n.level, true
}
}
return 0, false
}
// LevelNames lists the handles in the order of the scale.
func LevelNames() []string {
out := make([]string, len(levelNames))
for i, n := range levelNames {
out[i] = n.name
}
return out
}
// Mark labels a block of a rule. Marks set no obligation, they only say what
// this is: a rationale, an illustration, a note about mechanization, a stub in
// place of a retired rule.
type Mark int
const (
Rationale Mark = iota + 1
Examples
Mechanized
Retired
)
func (m Mark) String() string {
switch m {
case Rationale:
return "rationale"
case Examples:
return "examples"
case Mechanized:
return "mechanized"
case Retired:
return "retired"
}
return "unknown mark"
}
// Connective is a service word of a scenario block. Such words stay out of the
// language version line and out of the "modal words outside rules" check: they
// set no obligation, only structure.
type Connective int
const (
When Connective = iota + 1
Then
And
Or
)
// Vocabulary is the vocabulary of one language version in one natural language.
type Vocabulary struct {
Version int
Code string
Modals map[string]Level
Marks map[string]Mark
Scenario map[string]Connective
// Line is the language version line, with a single verb for the version
// number. Every convention names the language by one such line, and the
// wording around the words is as much a property of the natural language
// as the words themselves — which is why it lives here and not in a
// template inside the command that writes a new file.
Line string
}
// VersionLine renders the language version line for this vocabulary.
func (v Vocabulary) VersionLine() string {
return fmt.Sprintf(v.Line, v.Version)
}
// Modal reports the step of a word if the word belongs to this scale.
func (v Vocabulary) Modal(word string) (Level, bool) {
l, ok := v.Modals[word]
return l, ok
}
// Mark reports the role of a mark if the word belongs to these marks.
func (v Vocabulary) Mark(word string) (Mark, bool) {
m, ok := v.Marks[word]
return m, ok
}
// Word returns the word this vocabulary uses for a step.
func (v Vocabulary) Word(l Level) string {
for w, got := range v.Modals {
if got == l {
return w
}
}
return ""
}
// MarkWord returns the word this vocabulary uses for a mark.
func (v Vocabulary) MarkWord(m Mark) string {
for w, got := range v.Marks {
if got == m {
return w
}
}
return ""
}
// Lead returns the vocabulary word that opens text. The longest match wins:
// "MUST NOT" must not be read as "MUST", and a mark carrying a date
// ("RETIRED 2026-07-26") must not be read as a different mark.
func (v Vocabulary) Lead(text string) (string, bool) {
best := ""
for _, w := range v.Words() {
if len(w) <= len(best) {
continue
}
if !hasWordPrefix(text, w) {
continue
}
best = w
}
return best, best != ""
}
// Words lists every word the language names in its version line: the steps of
// the scale and the marks. Scenario connectives are not among them.
func (v Vocabulary) Words() []string {
words := make([]string, 0, len(v.Modals)+len(v.Marks))
for w := range v.Modals {
words = append(words, w)
}
for w := range v.Marks {
words = append(words, w)
}
sort.Strings(words)
return words
}
// hasWordPrefix reports whether text starts with word w and the word ends
// there: "MUSTARD" is not the word "MUST".
func hasWordPrefix(text, w string) bool {
if !strings.HasPrefix(text, w) {
return false
}
rest := text[len(w):]
if rest == "" {
return true
}
r, _ := utf8.DecodeRuneInString(rest)
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}
// registry holds the vocabularies known to the binary. The outer key is the
// language version, the inner one the code of the suite's natural language.
var registry = map[int]map[string]Vocabulary{
1: {
"ru": {
Version: 1,
Code: "ru",
Modals: map[string]Level{
"ДОЛЖЕН": Requirement,
"НЕ ДОЛЖЕН": Prohibition,
"СЛЕДУЕТ": Recommendation,
"НЕ СЛЕДУЕТ": RecommendationAgainst,
"ДОПУСКАЕТСЯ": Permission,
},
Marks: map[string]Mark{
"ПОЧЕМУ": Rationale,
"ПРИМЕРЫ": Examples,
"МЕХАНИЗИРОВАНО": Mechanized,
"СНЯТО": Retired,
},
Scenario: map[string]Connective{
"КОГДА": When,
"ТОГДА": Then,
"И": And,
"ИЛИ": Or,
},
Line: "Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки\n" +
"ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке\n" +
"конвенций версии %d — тогда и только тогда, когда написаны заглавными.",
},
"en": {
Version: 1,
Code: "en",
Modals: map[string]Level{
"MUST": Requirement,
"MUST NOT": Prohibition,
"SHOULD": Recommendation,
"SHOULD NOT": RecommendationAgainst,
"MAY": Permission,
},
Marks: map[string]Mark{
"WHY": Rationale,
"EXAMPLES": Examples,
"MECHANIZED": Mechanized,
"RETIRED": Retired,
},
Scenario: map[string]Connective{
"WHEN": When,
"THEN": Then,
"AND": And,
"OR": Or,
},
Line: "The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY,\n" +
"EXAMPLES, MECHANIZED and RETIRED are to be interpreted as described in the\n" +
"conventions language, version %d, and only when written in capitals.",
},
},
}
// Lookup returns the vocabulary of a language version in the given natural
// language.
func Lookup(version int, code string) (Vocabulary, error) {
byCode, ok := registry[version]
if !ok {
return Vocabulary{}, fmt.Errorf("language version %d is unknown to the tool; known versions: %s", version, versions())
}
v, ok := byCode[code]
if !ok {
return Vocabulary{}, fmt.Errorf("vocabulary %q of language version %d is unknown to the tool; known: %s", code, version, codes(version))
}
return v, nil
}
// Recognize picks the vocabulary a text is written in by the words it names.
//
// A copy in a consuming repository declares its language by the version line
// and by nothing else: no manifest travels with it, and no path back to the
// suite is written anywhere in it. So a check run outside a suite learns which
// words are normative the same way a reader does — off the line the document
// carries. Both halves have to agree: the words say which natural language,
// the number says which version of the language, and two versions of one
// natural language would otherwise be told apart by nothing at all.
func Recognize(text string) (Vocabulary, bool) {
for _, version := range sortedVersions() {
for _, code := range sortedCodes(version) {
v := registry[version][code]
if namesAll(text, v.Words()) && matchesVersion(text, v.Version) {
return v, true
}
}
}
return Vocabulary{}, false
}
func namesAll(text string, words []string) bool {
for _, w := range words {
if !strings.Contains(text, w) {
return false
}
}
return true
}
// matchesVersion looks for the number as a whole word, so that version 1 is not
// read out of "version 12".
func matchesVersion(text string, version int) bool {
number := fmt.Sprint(version)
for i := 0; ; {
j := strings.Index(text[i:], number)
if j < 0 {
return false
}
start, end := i+j, i+j+len(number)
before := start == 0 || !isDigit(rune(text[start-1]))
after := end == len(text) || !isDigit(rune(text[end]))
if before && after {
return true
}
i = end
if i >= len(text) {
return false
}
}
}
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
func sortedVersions() []int {
out := make([]int, 0, len(registry))
for v := range registry {
out = append(out, v)
}
sort.Ints(out)
return out
}
func sortedCodes(version int) []string {
out := make([]string, 0, len(registry[version]))
for c := range registry[version] {
out = append(out, c)
}
sort.Strings(out)
return out
}
// Foreign lists the words of the other vocabularies of the same version — the
// ones that give away a mixture of vocabularies. Words that coincide with the
// suite's own are dropped.
func Foreign(version int, code string) map[string]string {
own, err := Lookup(version, code)
if err != nil {
return nil
}
mine := make(map[string]bool)
for _, w := range own.Words() {
mine[w] = true
}
foreign := make(map[string]string)
for otherCode, other := range registry[version] {
if otherCode == code {
continue
}
for _, w := range other.Words() {
if !mine[w] {
foreign[w] = otherCode
}
}
}
return foreign
}
// ForeignConnectives lists the scenario connectives of the other vocabularies
// of the same version. They are kept apart from Foreign because they are caught
// differently: a connective is a whole word of everyday speech in some
// language — AND and OR are SQL keywords too — so only its position at the
// start of a line tells a scenario block from a mention.
func ForeignConnectives(version int, code string) map[string]string {
own, err := Lookup(version, code)
if err != nil {
return nil
}
foreign := make(map[string]string)
for otherCode, other := range registry[version] {
if otherCode == code {
continue
}
for w := range other.Scenario {
if _, mine := own.Scenario[w]; mine {
continue
}
foreign[w] = otherCode
}
}
return foreign
}
func versions() string {
out := make([]string, 0, len(registry))
for v := range registry {
out = append(out, fmt.Sprint(v))
}
sort.Strings(out)
return strings.Join(out, ", ")
}
func codes(version int) string {
out := make([]string, 0, len(registry[version]))
for c := range registry[version] {
out = append(out, c)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
+125
View File
@@ -0,0 +1,125 @@
package lang_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/lang"
)
func TestLeadTakesLongestMatch(t *testing.T) {
v, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
cases := []struct {
text string
want string
}{
{"ДОЛЖЕН.", "ДОЛЖЕН"},
{"НЕ ДОЛЖЕН.", "НЕ ДОЛЖЕН"},
{"НЕ СЛЕДУЕТ.", "НЕ СЛЕДУЕТ"},
{"СЛЕДУЕТ.", "СЛЕДУЕТ"},
{"СНЯТО 2026-07-26.", "СНЯТО"},
{"ПОЧЕМУ.", "ПОЧЕМУ"},
{"", ""},
{"Заголовок правила", ""},
{"ДОЛЖЕНСТВОВАНИЕ", ""},
}
for _, tc := range cases {
got, ok := v.Lead(tc.text)
if tc.want == "" {
if ok {
t.Errorf("Lead(%q) = %q, wanted no word to be found", tc.text, got)
}
continue
}
if !ok || got != tc.want {
t.Errorf("Lead(%q) = %q, %v; wanted %q", tc.text, got, ok, tc.want)
}
}
}
func TestForeignExcludesOwnWords(t *testing.T) {
foreign := lang.Foreign(1, "ru")
if len(foreign) == 0 {
t.Fatal("the Russian vocabulary yielded no foreign word at all")
}
if code, ok := foreign["MUST"]; !ok || code != "en" {
t.Errorf("MUST should be recognized as a word of the en vocabulary, got %q, %v", code, ok)
}
v, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
for word := range foreign {
if _, own := v.Modal(word); own {
t.Errorf("the word %q is the suite's own, yet landed among the foreign ones", word)
}
if _, own := v.Mark(word); own {
t.Errorf("the mark %q is the suite's own, yet landed among the foreign ones", word)
}
}
}
func TestUnknownVersionAndCode(t *testing.T) {
if _, err := lang.Lookup(99, "ru"); err == nil {
t.Error("an unknown language version was accepted without an error")
}
if _, err := lang.Lookup(1, "xx"); err == nil {
t.Error("an unknown vocabulary was accepted without an error")
}
}
// A copy in a consuming repository carries no manifest, so the only thing that
// says which words of it are normative is the line it names the language by.
func TestRecognizeReadsTheLanguageOffTheVersionLine(t *testing.T) {
ru, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
en, err := lang.Lookup(1, "en")
if err != nil {
t.Fatal(err)
}
for _, want := range []lang.Vocabulary{ru, en} {
got, ok := lang.Recognize(want.VersionLine())
if !ok {
t.Fatalf("the version line of %q was not recognized", want.Code)
}
if got.Code != want.Code || got.Version != want.Version {
t.Errorf("read as %q version %d, expected %q version %d",
got.Code, got.Version, want.Code, want.Version)
}
}
if _, ok := lang.Recognize("Обычная проза, ничего не объявляющая."); ok {
t.Errorf("prose naming no words passed for a version line")
}
}
// Words say which natural language, the number says which version. One without
// the other names no vocabulary: two versions of the same language would be
// told apart by nothing at all.
func TestRecognizeNeedsTheVersionToAgree(t *testing.T) {
ru, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
line := ru.VersionLine()
if _, ok := lang.Recognize(line); !ok {
t.Fatalf("the line the tool itself writes was not recognized")
}
wrong := strings.Replace(line, "версии 1", "версии 7", 1)
if wrong == line {
t.Fatal("the fixture did not change the version")
}
if v, ok := lang.Recognize(wrong); ok {
t.Errorf("a line naming version 7 was read as version %d (%s)", v.Version, v.Code)
}
}
+267
View File
@@ -0,0 +1,267 @@
// Package manifest reads and writes the two manifests of the model.
//
// The suite manifest declares three things: the language the suite's rules are
// written in, its live and retired topics, and its live and retired rule
// prefixes together with the paths of their files. The tool knows no topic and
// no prefix in advance — that whole list arrives from here.
//
// Both manifests are data the tool edits, so both are decoded into structs and
// written back out of them. They carry no comments: a file a machine rewrites
// cannot keep a comment through the round trip, and pretending otherwise costs
// the comment on a day nobody is watching. What a topic is for is said in the
// documents next to the manifest, which no command touches.
//
// Because a write goes out of the structs, a key the tool does not know would
// disappear on the next edit. So it does not write at all while one is there:
// a refusal naming the key is the only outcome that neither loses it nor hides
// it.
package manifest
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/BurntSushi/toml"
)
// Name is the name of a suite manifest. Which of the two manifests lies next
// to you tells you where you are, and both start with a dot for the same
// reason: a manifest is data the tool writes, not a document of the repository,
// and it sits with the rest of the service files rather than among the
// conventions themselves.
const Name = ".conventions-suite.toml"
// DefaultLanguageCode is the suite's natural language when the manifest says
// nothing about it. The key is optional on purpose: the vocabulary lives in the
// binary, and making every suite declare what is already implied buys nothing.
const DefaultLanguageCode = "ru"
// Language is the [language] section: the version of the conventions language,
// the natural language its words are written in, and the two documents about
// it. The full description stays with the author of the suite, the short one
// travels into the copy.
//
// Source is where those two documents live. Empty means the suite itself, which
// is where they lie while the specification of the language has no repository
// of its own; once it moves out, the same key names it without anything else
// changing — the vocabulary is picked by version and code either way.
type Language struct {
Version int `toml:"version"`
Lang string `toml:"lang,omitempty"`
Source string `toml:"source,omitempty"`
Description string `toml:"description,omitempty"`
Reading string `toml:"reading,omitempty"`
}
// Section is a part of the manifest split into a live and a retired half.
// Retired entries are kept rather than deleted: a topic name and a rule prefix
// live on in foreign repositories, and neither may ever be reused.
type Section struct {
Live map[string]string `toml:"live,omitempty"`
Retired map[string]string `toml:"retired,omitempty"`
}
// Add puts an entry into the live half, making the map if there is none.
func (s *Section) Add(key, value string) {
if s.Live == nil {
s.Live = make(map[string]string)
}
s.Live[key] = value
}
// Retire moves an entry out of the live half into the retired one. A name is
// never deleted and never reissued: it lives on in foreign repositories, and a
// name handed out twice starts pointing at something else there.
func (s *Section) Retire(key, note string) {
delete(s.Live, key)
if s.Retired == nil {
s.Retired = make(map[string]string)
}
s.Retired[key] = note
}
// Manifest is a parsed suite manifest.
type Manifest struct {
Language Language `toml:"language"`
// Governance is the path of the document the suite governs itself by: the
// one written in the conventions language yet belonging to no topic, so
// that nobody can subscribe to it and it travels nowhere.
//
// It is declared rather than guessed. A convention that lost its topic key
// looks exactly like it, and the loss is the expensive one — the file keeps
// every check of form while quietly dropping every check about travelling
// to a consumer.
Governance string `toml:"governance,omitempty"`
Topics Section `toml:"topics,omitempty"`
Prefixes Section `toml:"prefixes,omitempty"`
// Path is where the manifest was read from.
Path string `toml:"-"`
// Undecoded lists keys the tool does not know. A typo in the manifest
// would otherwise pass in silence, and it costs a subscription or a
// whole file.
Undecoded []string `toml:"-"`
}
// Load reads the suite manifest from directory root.
func Load(root string) (*Manifest, error) {
path := filepath.Join(root, Name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading the suite manifest: %w", err)
}
var m Manifest
meta, err := toml.Decode(string(data), &m)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
m.Path = path
for _, key := range meta.Undecoded() {
m.Undecoded = append(m.Undecoded, key.String())
}
sort.Strings(m.Undecoded)
if m.Language.Lang == "" {
m.Language.Lang = DefaultLanguageCode
}
return &m, nil
}
// Save writes the manifest back to where it was read from.
func (m *Manifest) Save() error {
return save(m.Path, m, m.Undecoded)
}
// save encodes a manifest and puts it in place.
func save(path string, value any, undecoded []string) error {
if len(undecoded) > 0 {
return fmt.Errorf("%s holds %s the tool does not know (%s); a write goes out of what the tool understands, so the key would be dropped — fix the spelling first",
path, plural(len(undecoded), "key"), strings.Join(undecoded, ", "))
}
var b bytes.Buffer
enc := toml.NewEncoder(&b)
enc.Indent = ""
if err := enc.Encode(value); err != nil {
return fmt.Errorf("encoding %s: %w", path, err)
}
return os.WriteFile(path, b.Bytes(), 0o644)
}
func plural(n int, noun string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, noun)
}
return fmt.Sprintf("%d %ss", n, noun)
}
// Find walks up from start looking for a directory that holds a suite
// manifest, so that `convy suite check` works from any subdirectory of a suite.
func Find(start string) (string, error) {
return findUp(start, Name)
}
// findUp walks up from start looking for a directory holding the named file.
func findUp(start, name string) (string, error) {
dir, err := filepath.Abs(start)
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", ErrNotFound
}
dir = parent
}
}
// ErrNotFound means there is no suite manifest here or above.
var ErrNotFound = errors.New("suite manifest not found")
// LivePrefixes lists the live prefixes in an order stable between runs: the
// output of a check must not depend on map iteration.
func (m *Manifest) LivePrefixes() []string {
return sortedKeys(m.Prefixes.Live)
}
// LiveTopics lists the live topics in a stable order.
func (m *Manifest) LiveTopics() []string {
return sortedKeys(m.Topics.Live)
}
// PrefixOf returns the prefix declared for a file, if there is one. Paths are
// compared in slash form, the way the manifest writes them.
func (m *Manifest) PrefixOf(path string) (string, bool) {
want := filepath.ToSlash(path)
for prefix, declared := range m.Prefixes.Live {
if filepath.ToSlash(declared) == want {
return prefix, true
}
}
return "", false
}
// PathOf returns the path declared for a live prefix.
func (m *Manifest) PathOf(prefix string) (string, bool) {
path, ok := m.Prefixes.Live[prefix]
return path, ok
}
// Governs reports whether the path is the document the suite governs itself by.
func (m *Manifest) Governs(path string) bool {
return m.Governance != "" && filepath.ToSlash(m.Governance) == filepath.ToSlash(path)
}
// TopicLive reports whether the topic is declared among the live ones.
func (m *Manifest) TopicLive(topic string) bool {
_, ok := m.Topics.Live[topic]
return ok
}
// TopicRetired reports whether the topic is listed among the retired ones.
func (m *Manifest) TopicRetired(topic string) bool {
_, ok := m.Topics.Retired[topic]
return ok
}
// PrefixRetired reports whether the prefix is listed among the retired ones.
func (m *Manifest) PrefixRetired(prefix string) bool {
_, ok := m.Prefixes.Retired[prefix]
return ok
}
// ValidPrefix checks the shape of a prefix: four uppercase Latin letters. The
// letter X in first position is reserved for consuming repositories, and the
// suite never takes it.
func ValidPrefix(prefix string) error {
if len(prefix) != 4 {
return fmt.Errorf("prefix %q is not four letters", prefix)
}
for _, r := range prefix {
if r < 'A' || r > 'Z' {
return fmt.Errorf("prefix %q holds a character that is not an uppercase Latin letter", prefix)
}
}
if strings.HasPrefix(prefix, "X") {
return fmt.Errorf("prefix %q starts with X, a letter reserved for the local rules of consumers", prefix)
}
return nil
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+168
View File
@@ -0,0 +1,168 @@
package manifest_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/manifest"
)
func write(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// A manifest is data, and a command that changes it rewrites it whole. The
// second write of the same content has to come out the same, or every command
// would leave a diff of its own on top of the one it meant.
func TestSaveIsIdempotent(t *testing.T) {
dir := t.TempDir()
write(t, dir, manifest.Name, `[language]
version = 1
lang = "ru"
[topics.live]
time = "время"
[prefixes.live]
TIME = "conventions/time.md"
`)
m, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.Save(); err != nil {
t.Fatal(err)
}
once, err := os.ReadFile(m.Path)
if err != nil {
t.Fatal(err)
}
again, err := manifest.Load(dir)
if err != nil {
t.Fatalf("the manifest the tool wrote does not load: %v\n%s", err, once)
}
if err := again.Save(); err != nil {
t.Fatal(err)
}
twice, err := os.ReadFile(m.Path)
if err != nil {
t.Fatal(err)
}
if string(once) != string(twice) {
t.Errorf("the second write differs from the first:\n%s\n---\n%s", once, twice)
}
}
// A write goes out of the structs, so a key the tool does not know would be
// dropped. It refuses instead: that neither loses the key nor hides it.
func TestSaveRefusesWhileAKeyIsUnknown(t *testing.T) {
dir := t.TempDir()
write(t, dir, manifest.Name, `[language]
version = 1
descriptoin = "LANGUAGE.md"
`)
m, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(m.Path)
err = m.Save()
if err == nil {
t.Fatal("the manifest was rewritten over a key the tool does not know")
}
if !strings.Contains(err.Error(), "descriptoin") {
t.Errorf("the refusal does not name the key: %s", err)
}
after, _ := os.ReadFile(m.Path)
if string(before) != string(after) {
t.Errorf("the file was touched anyway:\n%s", after)
}
}
// A name never leaves the manifest: it lives on in foreign repositories, and
// one handed out twice starts pointing at something else there.
func TestRetireMovesRatherThanDeletes(t *testing.T) {
dir := t.TempDir()
write(t, dir, manifest.Name, `[language]
version = 1
[topics.live]
time = "время"
logging = "логирование"
`)
m, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
m.Topics.Retire("logging", "2026-07-28: свёрнута в errors")
if err := m.Save(); err != nil {
t.Fatal(err)
}
back, err := manifest.Load(dir)
if err != nil {
t.Fatal(err)
}
if back.TopicLive("logging") {
t.Errorf("the topic stayed live")
}
if !back.TopicRetired("logging") {
t.Errorf("the topic is neither live nor retired: the name is loose")
}
if !back.TopicLive("time") {
t.Errorf("the other topic went with it")
}
}
func TestProjectRoundTrip(t *testing.T) {
dir := t.TempDir()
p := &manifest.Project{
Source: "../dev-conventions#v2",
Components: map[string]manifest.Component{
"backend": {Dir: `docs\conventions`, Lang: []string{"go"}, Topics: []string{"time"}},
"web": {Dir: "web/docs", Topics: []string{}},
},
Path: filepath.Join(dir, manifest.ProjectName),
Root: dir,
}
if err := p.Save(); err != nil {
t.Fatal(err)
}
back, err := manifest.LoadProject(dir)
if err != nil {
t.Fatalf("the manifest the tool wrote does not load: %v", err)
}
if back.Source != p.Source {
t.Errorf("source came back as %q", back.Source)
}
// A backslash is the ordinary way a written value fails to read back.
if got := back.Components["backend"].Dir; got != `docs\conventions` {
t.Errorf("the directory came back as %q", got)
}
if len(back.Components["web"].Lang) != 0 {
t.Errorf("an empty axis was written and read back as something")
}
back.Subscribe("web", "logging")
back.Subscribe("web", "errors")
if got := back.Components["web"].Topics; strings.Join(got, ",") != "errors,logging" {
t.Errorf("the subscription is not kept in order: %v", got)
}
back.Unsubscribe("web", "errors")
if got := back.Components["web"].Topics; strings.Join(got, ",") != "logging" {
t.Errorf("unsubscribing left %v", got)
}
}
+140
View File
@@ -0,0 +1,140 @@
package manifest
import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/BurntSushi/toml"
)
// ProjectName is the name of the manifest of a consuming repository. Which of
// the two manifests lies next to you tells you where you are, so the project
// one is never named like the suite one.
const ProjectName = ".conventions.toml"
// Component is one addressee of assembly inside a project: a region where every
// selected layer holds at once — one language, one set of tools, one kind of
// application (META-36).
//
// Lang and Stack are lists because a component may well take two stack layers
// at a time — sqlite and postgres in the schema topic hold together, being
// different tables of one service. Two languages never do: a line of code is
// written in one of them, which is what a component exists to separate.
type Component struct {
Dir string `toml:"dir"`
Lang []string `toml:"lang,omitempty"`
Stack []string `toml:"stack,omitempty"`
Topics []string `toml:"topics"`
}
// Project is a parsed .conventions.toml: where the copies are taken from and
// which components take what.
type Project struct {
// Source is the reference to the suite. How the tool reaches it — a path
// on disk, a git repository — is a matter of the reference itself.
Source string `toml:"source"`
Components map[string]Component `toml:"components"`
// Path is where the manifest was read from.
Path string `toml:"-"`
// Root is the directory the manifest lies in; every path in it is relative
// to that directory.
Root string `toml:"-"`
// Undecoded lists keys the tool does not know: a typo in a component name
// or in a key would otherwise cost a whole subscription in silence.
Undecoded []string `toml:"-"`
}
// LoadProject reads the project manifest from directory root.
func LoadProject(root string) (*Project, error) {
path := filepath.Join(root, ProjectName)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading the project manifest: %w", err)
}
var p Project
meta, err := toml.Decode(string(data), &p)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
p.Path = path
p.Root = root
for _, key := range meta.Undecoded() {
p.Undecoded = append(p.Undecoded, key.String())
}
sort.Strings(p.Undecoded)
return &p, nil
}
// Save writes the project manifest back to where it was read from.
func (p *Project) Save() error {
return save(p.Path, p, p.Undecoded)
}
// Subscribe adds a topic to a component, keeping the list sorted so that the
// file does not churn on the order things were added in.
func (p *Project) Subscribe(name, topic string) {
c := p.Components[name]
c.Topics = append(c.Topics, topic)
sort.Strings(c.Topics)
p.Components[name] = c
}
// Unsubscribe drops a topic from a component. Nothing is kept behind: a
// subscription is a choice of the project, not a name anyone else may reuse.
func (p *Project) Unsubscribe(name, topic string) {
c := p.Components[name]
c.Topics = slices.DeleteFunc(c.Topics, func(t string) bool { return t == topic })
p.Components[name] = c
}
// FindProject walks up from start looking for a project manifest, so that a
// command works from any subdirectory of a repository.
func FindProject(start string) (string, error) {
return findUp(start, ProjectName)
}
// Names lists the components in an order stable between runs.
func (p *Project) Names() []string {
names := make([]string, 0, len(p.Components))
for name := range p.Components {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Only picks the component a command works on. With a name given it is that
// one; without a name it is the single component of the project. A project of
// several components does not get one guessed for it — it gets the list.
func (p *Project) Only(name string) (string, Component, error) {
if name != "" {
c, ok := p.Components[name]
if !ok {
return "", Component{}, fmt.Errorf("the project declares no component %q; it declares: %s", name, joinNames(p.Names()))
}
return name, c, nil
}
switch len(p.Components) {
case 0:
return "", Component{}, fmt.Errorf("%s declares no component, and a copy is assembled for a component", p.Path)
case 1:
only := p.Names()[0]
return only, p.Components[only], nil
}
return "", Component{}, fmt.Errorf("the project holds several components, and the command names none: pass --for with one of %s", joinNames(p.Names()))
}
// Subscribed reports whether a component takes a topic.
func (c Component) Subscribed(topic string) bool {
return slices.Contains(c.Topics, topic)
}
func joinNames(names []string) string {
return strings.Join(names, ", ")
}
+220
View File
@@ -0,0 +1,220 @@
// Package project assembles copies of conventions inside a consuming
// repository.
//
// A copy is flat: one file per topic, the layers of the topic inside it as
// sections in the order base, language, stack. The paths of the suite are not
// reproduced — whoever checks code against a convention reads one file and does
// not gather a topic out of three places.
//
// Everything below the local marker belongs to the repository and survives
// reassembly; everything above it is rewritten. There is no three-way merge and
// no report of divergence: after a reassembly the difference is shown by git,
// and the decision is taken by a person before the commit.
package project
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// LocalMarker is the boundary between what the suite wrote and what the
// repository wrote. It is defined once, next to the parsing that has to respect
// it, and named again here because assembly is where it is placed.
const LocalMarker = doc.LocalMarker
// Copy is the outcome of assembling one topic for one component.
type Copy struct {
Topic string
// Path is where the file lies, from the root of the project.
Path string
// Layers lists the paths in the suite the file was built from.
Layers []string
// Created says the file did not exist before.
Created bool
// Kept says a local part below the marker was carried over.
Kept bool
}
// Axis is what a component asks the suite for.
func Axis(c manifest.Component) suite.Component {
return suite.Component{Lang: c.Lang, Stack: c.Stack}
}
// Assemble builds one topic for one component and writes the file. root is the
// root of the project.
func Assemble(s *suite.Suite, root string, c manifest.Component, topic string) (Copy, error) {
if !s.Manifest.TopicLive(topic) {
if s.Manifest.TopicRetired(topic) {
return Copy{}, fmt.Errorf("the suite has retired the topic %q: %s", topic, s.Manifest.Topics.Retired[topic])
}
return Copy{}, fmt.Errorf("the suite declares no live topic %q", topic)
}
layers, _ := s.Assemble(topic, Axis(c))
if len(layers) == 0 {
return Copy{}, fmt.Errorf("the topic %q has no layer this component takes", topic)
}
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
name := filepath.Join(root, filepath.FromSlash(rel))
made := Copy{Topic: topic, Path: rel}
for _, d := range layers {
made.Layers = append(made.Layers, d.Path)
}
local := LocalMarker + "\n"
existing, err := os.ReadFile(name)
switch {
case os.IsNotExist(err):
made.Created = true
case err != nil:
return Copy{}, err
default:
kept, err := ours(rel, string(existing), topic)
if err != nil {
return Copy{}, err
}
local = kept
}
made.Kept = strings.TrimSpace(strings.TrimPrefix(local, LocalMarker)) != ""
body := Render(topic, layers, s.Vocab) + "\n\n" + local
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return Copy{}, err
}
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
return Copy{}, err
}
return made, nil
}
// ReadingName is what the reader's guide of the language is called next to the
// copies, whatever it is called where it came from.
const ReadingName = "READING.md"
// Reading puts the reader's guide of the language next to the copies. The guide
// belongs to the level above and is overwritten whole; README.md standing next
// to it belongs to the repository and is never touched.
//
// The guide travels because the copy names the language by a version and by no
// path: without the guide an agent reading a copy takes ДОПУСКАЕТСЯ for the
// everyday "you can" and loses exactly what the word was introduced for.
func Reading(langRoot, path, root, dir string) (string, error) {
if path == "" {
return "", fmt.Errorf("the suite manifest names no reading guide, and it is what travels next to the copies")
}
body, err := os.ReadFile(filepath.Join(langRoot, filepath.FromSlash(path)))
if err != nil {
return "", fmt.Errorf("reading the guide of the language: %w", err)
}
rel := filepath.ToSlash(filepath.Join(dir, ReadingName))
name := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(name, body, 0o644); err != nil {
return "", err
}
return rel, nil
}
// ours decides whether a file standing in the way may be rewritten, and returns
// the part of it that survives: everything from the marker down.
//
// Three ways it may not. A file whose origin key was taken away has stopped
// being a copy and become a document of the repository. A file carrying the
// origin of another topic is another copy that would be buried by this one. And
// a file with no marker cannot be rewritten either — the marker is always
// placed by the assembler, so a copy without one was edited by hand, and
// everything in it counts as suite text that assembly would silently replace.
func ours(rel, existing, topic string) (string, error) {
d, err := doc.Parse(rel, existing)
if err != nil {
return "", fmt.Errorf("%s is in the way and cannot be read: %w", rel, err)
}
switch {
case d.Front.Origin == "" && d.Front.Topic == "" && d.Front.Prefix == "":
return "", fmt.Errorf("%s carries no origin key: it is a document of the repository rather than a copy, and assembly would overwrite it", rel)
case d.Front.Origin == "":
return "", fmt.Errorf("%s carries no origin key, while it does carry the front matter of a suite file: it looks like a layer put here by hand", rel)
case d.Front.Origin != topic:
return "", fmt.Errorf("%s is a copy of the topic %q, and the topic %q would be assembled into the same file", rel, d.Front.Origin, topic)
}
marker := d.Marker()
if marker == 0 {
return "", fmt.Errorf("%s carries no %s marker, and the assembler always leaves one: whatever is in the file was written above the boundary and would be replaced without trace", rel, LocalMarker)
}
return strings.TrimRight(d.Below(marker), "\n") + "\n", nil
}
// Render lays out the part of a copy that comes from the suite: the front
// matter of the copy and the layers of the topic.
func Render(topic string, layers []*doc.Document, v lang.Vocabulary) string {
parts := make([]string, 0, len(layers)+1)
parts = append(parts, "---\norigin: "+topic+"\n---")
for i, d := range layers {
parts = append(parts, layerText(d, v, i == 0))
}
return strings.Join(parts, "\n\n")
}
// layerText renders one layer.
//
// The first layer is the document: its title, its introduction, its sections.
// Every layer after it becomes a section of that document, because a layer only
// implements and narrows the base rather than standing beside it — so its own
// headings step down one level and its title becomes the heading of the
// section. The line about the language version is dropped from all but the
// first: it says the same thing three times over otherwise, and it is what
// makes the copy self-contained rather than decoration to be repeated.
func layerText(d *doc.Document, v lang.Vocabulary, first bool) string {
skip := map[int]bool{}
if !first {
if from, to, ok := versionLines(d, v); ok {
for n := from; n <= to; n++ {
skip[n] = true
}
}
}
var out []string
for n := d.Body; n <= d.Len(); n++ {
if skip[n] {
continue
}
line := d.Line(n)
if !first && !d.Fenced(n) && strings.HasPrefix(line, "#") {
line = "#" + line
}
out = append(out, line)
}
return strings.Trim(strings.Join(out, "\n"), "\n")
}
// versionLines finds the paragraph carrying the language version line: the one
// listing every key word of the vocabulary.
func versionLines(d *doc.Document, v lang.Vocabulary) (from, to int, ok bool) {
start, end := d.Preamble()
words := v.Words()
for _, p := range d.Paragraphs(start, end) {
text := p.Text()
found := true
for _, w := range words {
if !strings.Contains(text, w) {
found = false
break
}
}
if found {
return p.Start, p.End, true
}
}
return 0, 0, false
}
+133
View File
@@ -0,0 +1,133 @@
package project_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/project"
)
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
const baseLayer = `---
topic: time
prefix: TIME
---
# Время
Как приложение записывает моменты.
` + versionLine + `
## Правила
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
const goLayer = `---
topic: time
prefix: GTIM
lang: go
extends: arch/time.md
---
# Время: реализация на Go
Как базовый слой выполняется в Go-коде.
` + versionLine + `
## Правила
### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из ` + "`store.Now()`" + `.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
`
func layers(t *testing.T, bodies ...string) []*doc.Document {
t.Helper()
v, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
var out []*doc.Document
for i, body := range bodies {
d, err := doc.Parse("layer.md", body)
if err != nil {
t.Fatalf("layer %d: %v", i, err)
}
d.Blocks(v)
out = append(out, d)
}
return out
}
func vocab(t *testing.T) lang.Vocabulary {
t.Helper()
v, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
return v
}
// A copy is one document rather than three files glued together: the first
// layer is the document, and every layer after it is a section of it.
func TestRenderMakesTheLayersSectionsOfOneDocument(t *testing.T) {
body := project.Render("time", layers(t, baseLayer, goLayer), vocab(t))
for _, want := range []string{
"---\norigin: time\n---",
"\n# Время\n",
"\n## Правила\n",
"\n### TIME-1. Момент записывается в UTC\n",
"\n## Время: реализация на Go\n",
"\n### Правила\n",
"\n#### GTIM-1. «Сейчас» берётся у слоя хранилища\n",
} {
if !strings.Contains(body, want) {
t.Errorf("the copy lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "topic: time\nprefix:") {
t.Errorf("the front matter of a layer travelled into the copy:\n%s", body)
}
if strings.Contains(body, "extends:") {
t.Errorf("a path of the suite travelled into the copy:\n%s", body)
}
}
// The line about the language is what makes a copy readable on its own, and
// saying it three times over says nothing more than saying it once.
func TestRenderNamesTheLanguageOnce(t *testing.T) {
body := project.Render("time", layers(t, baseLayer, goLayer), vocab(t))
if n := strings.Count(body, "толкуются как описано"); n != 1 {
t.Errorf("the language version line appears %d times:\n%s", n, body)
}
if !strings.Contains(body, "конвенций версии 1") {
t.Errorf("the language version line is gone altogether:\n%s", body)
}
}
// A single layer is not a special case: assembly out of one layer is a copy of
// it, with no section headings invented around it.
func TestRenderOfOneLayerKeepsItsLevels(t *testing.T) {
body := project.Render("time", layers(t, baseLayer), vocab(t))
if !strings.Contains(body, "\n# Время\n") || !strings.Contains(body, "\n### TIME-1.") {
t.Errorf("a single layer was restructured:\n%s", body)
}
if strings.Contains(body, "####") {
t.Errorf("headings of a single layer were demoted:\n%s", body)
}
}
+236
View File
@@ -0,0 +1,236 @@
// Package source resolves a reference from one level of the model to the level
// above it.
//
// The levels stack up: the language describes how a rule is written, a suite
// writes its rules in that language, a project takes copies out of a suite.
// Each level is a set of files, and each lower one names the level above by a
// reference. Where that set physically lies is a question of transport rather
// than of the model — a directory on disk, a git repository, one day an HTTP
// tree or an rclone remote — so a reference names the transport first.
//
// Two transports are implemented: a path on disk and a git repository. Kind is
// an enumeration rather than a boolean because the third one is expected, and
// because a reference that cannot be resolved has to say which transport it was
// understood as before it says what went wrong.
package source
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// Kind is the transport a reference names.
type Kind int
const (
// Local is a directory on disk.
Local Kind = iota + 1
// Git is a repository cloned to read from.
Git
)
func (k Kind) String() string {
switch k {
case Local:
return "path"
case Git:
return "git"
}
return "unknown"
}
// Ref is a parsed reference to a level.
type Ref struct {
// Raw is the reference as the manifest wrote it.
Raw string
// Kind is the transport.
Kind Kind
// Location is the path or the URL of the repository, without the revision.
Location string
// Rev is a git branch, tag or commit. Empty means the default branch of
// the repository.
Rev string
}
// String renders the reference back the way it was written.
func (r Ref) String() string {
if r.Rev == "" {
return r.Location
}
return r.Location + "#" + r.Rev
}
// scpRe catches the short form git accepts instead of a URL: user@host:path.
var scpRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]+@[A-Za-z0-9_.\-]+:`)
// schemes maps a URL scheme onto the transport that serves it. Everything git
// speaks is a git reference: http and https are the two the tool is written
// against, while ssh, git and file happen to work because the clone is the same
// clone.
//
// file:// is a git reference rather than a directory on purpose. A plain path
// already says "this directory as it lies", working tree and all; file:// says
// "the same repository as it is committed", which is a different and sometimes
// wanted thing — and the difference is the reason both spellings exist.
var schemes = map[string]Kind{
"http": Git,
"https": Git,
"ssh": Git,
"git": Git,
"file": Git,
}
// Parse reads a reference. The form is "<location>" or "<location>#<revision>";
// what the location starts with decides the transport.
func Parse(raw string) (Ref, error) {
text := strings.TrimSpace(raw)
if text == "" {
return Ref{}, errors.New("the source reference is empty")
}
location, rev := text, ""
if i := strings.LastIndex(text, "#"); i >= 0 {
location, rev = strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
if location == "" {
return Ref{}, fmt.Errorf("the reference %q names a revision and nothing to take it from", text)
}
if rev == "" {
return Ref{}, fmt.Errorf("the reference %q ends with # and names no revision", text)
}
}
ref := Ref{Raw: text, Location: location, Rev: rev}
scheme, _, hasScheme := strings.Cut(location, "://")
switch {
case hasScheme:
kind, known := schemes[scheme]
if !known {
return Ref{}, fmt.Errorf("the reference %q names the scheme %q, and the tool reaches a level over a path on disk or over git", text, scheme)
}
ref.Kind = kind
case scpRe.MatchString(location):
ref.Kind = Git
default:
ref.Kind = Local
}
if ref.Kind == Local {
if rev != "" {
return Ref{}, fmt.Errorf("the reference %q pins a revision of a directory on disk: a revision is a thing only a git repository has", text)
}
// A manifest is committed and travels between machines, and a path
// through a home directory means a different place on each of them. A
// relative path resolves against the manifest, which is the form that
// survives the trip.
if strings.HasPrefix(ref.Location, "~") {
return Ref{}, fmt.Errorf("the reference %q starts from a home directory, which points somewhere else on every other machine; write it relative to the manifest or in full", text)
}
}
return ref, nil
}
// Tree is a level laid out as a directory that can be read.
type Tree struct {
ref Ref
dir string
temp bool
}
// Dir is the root of the level on disk.
func (t *Tree) Dir() string { return t.dir }
// Describe rewrites a message about the tree in terms of the reference it came
// from. A fetched level lies in a temporary directory whose name says nothing
// to anyone: what the reader can act on is the reference they wrote.
func (t *Tree) Describe(err error) error {
if err == nil || t == nil {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), t.dir, t.ref.String()))
}
// Close releases whatever the opening took. A directory on disk was there
// before and stays; a clone is removed.
func (t *Tree) Close() error {
if t == nil || !t.temp {
return nil
}
return os.RemoveAll(t.dir)
}
// Open makes a reference readable. A relative path resolves against base — the
// directory of the manifest that carries the reference.
//
// A git repository is cloned afresh every time, into a directory that goes away
// with the Tree. A cache would spare the second clone and buy back the question
// of what is stale in it, and the answer to "what did it look like last time"
// belongs to git in the consuming repository rather than to a cache of the tool.
func Open(r Ref, base string) (*Tree, error) {
switch r.Kind {
case Local:
dir := filepath.FromSlash(r.Location)
if !filepath.IsAbs(dir) {
dir = filepath.Join(base, dir)
}
info, err := os.Stat(dir)
if err != nil {
return nil, fmt.Errorf("the source %s: %w", r.Raw, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("the source %s is a file, while a level is a directory", r.Raw)
}
return &Tree{ref: r, dir: dir}, nil
case Git:
return clone(r)
}
return nil, fmt.Errorf("the source %s names no transport the tool knows", r.Raw)
}
// clone fetches a git reference into a temporary directory.
func clone(r Ref) (*Tree, error) {
if _, err := exec.LookPath("git"); err != nil {
return nil, fmt.Errorf("the source %s is a git repository, and there is no git in PATH to fetch it with", r.Raw)
}
dir, err := os.MkdirTemp("", "convy-source-")
if err != nil {
return nil, err
}
args := []string{"clone", "--quiet", "--depth", "1"}
if r.Rev != "" {
args = append(args, "--branch", r.Rev)
}
args = append(args, r.Location, dir)
out, err := git(args...)
if err == nil {
return &Tree{ref: r, dir: dir, temp: true}, nil
}
// A commit hash is not a branch and not a tag, so --branch turns it down.
// Reaching one costs the whole history, which is why it is the second
// attempt rather than the first.
if r.Rev != "" {
if _, deep := git("clone", "--quiet", r.Location, dir); deep == nil {
if _, at := git("-C", dir, "checkout", "--quiet", r.Rev); at == nil {
return &Tree{ref: r, dir: dir, temp: true}, nil
}
}
}
os.RemoveAll(dir)
return nil, fmt.Errorf("fetching the source %s: %w\n%s", r.Raw, err, strings.TrimSpace(out))
}
func git(args ...string) (string, error) {
cmd := exec.Command("git", args...)
// A clone that stops to ask for a password would hang a command meant to
// run unattended; failing with what git said is the answer that can be
// acted on.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
return string(out), err
}
+163
View File
@@ -0,0 +1,163 @@
package source_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/source"
)
func TestParseTellsTheTransportsApart(t *testing.T) {
cases := []struct {
raw string
kind source.Kind
location string
rev string
}{
{"../dev-conventions", source.Local, "../dev-conventions", ""},
{"/srv/conventions", source.Local, "/srv/conventions", ""},
{"https://git.example.org/av/conventions.git", source.Git, "https://git.example.org/av/conventions.git", ""},
{"http://git.example.org/av/conventions.git#v2", source.Git, "http://git.example.org/av/conventions.git", "v2"},
{"ssh://git@git.example.org:2222/av/conventions.git", source.Git, "ssh://git@git.example.org:2222/av/conventions.git", ""},
{"git@git.example.org:av/conventions.git", source.Git, "git@git.example.org:av/conventions.git", ""},
// A plain path is the directory as it lies; file:// is the same
// repository as it is committed.
{"file:///srv/conventions#main", source.Git, "file:///srv/conventions", "main"},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
ref, err := source.Parse(tc.raw)
if err != nil {
t.Fatalf("parsing %q: %v", tc.raw, err)
}
if ref.Kind != tc.kind {
t.Errorf("read as %s, expected %s", ref.Kind, tc.kind)
}
if ref.Location != tc.location {
t.Errorf("location %q, expected %q", ref.Location, tc.location)
}
if ref.Rev != tc.rev {
t.Errorf("revision %q, expected %q", ref.Rev, tc.rev)
}
})
}
}
func TestParseRefusesWhatItCannotMean(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"nothing at all", " ", "empty"},
{"a revision of a directory", "../conventions#main", "only a git repository has"},
{"a home directory", "~/projects/conventions", "every other machine"},
{"a scheme nobody serves", "rclone://remote/conventions", "over git"},
{"a hash with no revision", "https://git.example.org/c.git#", "names no revision"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := source.Parse(tc.raw)
if err == nil {
t.Fatalf("the reference %q went through", tc.raw)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("the refusal does not say %q: %s", tc.want, err)
}
})
}
}
func TestOpenResolvesAPathAgainstTheManifest(t *testing.T) {
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "vendor", "conventions"), 0o755); err != nil {
t.Fatal(err)
}
ref, err := source.Parse("vendor/conventions")
if err != nil {
t.Fatal(err)
}
tree, err := source.Open(ref, base)
if err != nil {
t.Fatalf("opening a relative path: %v", err)
}
defer tree.Close()
if tree.Dir() != filepath.Join(base, "vendor", "conventions") {
t.Errorf("resolved to %s", tree.Dir())
}
// Nothing was fetched, so nothing is released: the directory was there
// before the command and stays after it.
tree.Close()
if _, err := os.Stat(tree.Dir()); err != nil {
t.Errorf("closing removed a directory that was not fetched: %v", err)
}
}
func TestOpenRefusesAFileWhereALevelIsExpected(t *testing.T) {
base := t.TempDir()
if err := os.WriteFile(filepath.Join(base, "conventions"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ref, _ := source.Parse("conventions")
if _, err := source.Open(ref, base); err == nil || !strings.Contains(err.Error(), "a level is a directory") {
t.Errorf("a file passed as a level: %v", err)
}
}
// A git reference takes what is committed rather than what lies in the working
// tree, and that is the difference between the two spellings of a local suite.
func TestOpenGitTakesTheCommittedState(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git in PATH")
}
repo := t.TempDir()
name := filepath.Join(repo, ".conventions-suite.toml")
if err := os.WriteFile(name, []byte("committed\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"init", "--quiet", "-b", "main"},
{"-c", "user.email=t@example.org", "-c", "user.name=t", "add", ".conventions-suite.toml"},
{"-c", "user.email=t@example.org", "-c", "user.name=t", "commit", "--quiet", "-m", "first"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
if err := os.WriteFile(name, []byte("uncommitted\n"), 0o644); err != nil {
t.Fatal(err)
}
ref, err := source.Parse("file://" + repo + "#main")
if err != nil {
t.Fatal(err)
}
tree, err := source.Open(ref, ".")
if err != nil {
t.Fatalf("cloning a local repository: %v", err)
}
body, err := os.ReadFile(filepath.Join(tree.Dir(), ".conventions-suite.toml"))
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(body)) != "committed" {
t.Errorf("the working tree leaked into the clone: %q", body)
}
dir := tree.Dir()
if err := tree.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(dir); err == nil {
t.Errorf("the clone survived the close: %s", dir)
}
}
+80
View File
@@ -0,0 +1,80 @@
package suite
import (
"slices"
"git.vakhrushev.me/av/convy/internal/doc"
)
// Component is what a copy is assembled for: one language, one set of tools,
// one kind of application. Both axes may stay empty — a flat suite has no axes
// at all, and a component of it selects the base layer and nothing else.
//
// Each axis is a list because a component may take two stack layers at once:
// sqlite and postgres in the schema topic hold together, being different tables
// of one service. Two languages never hold together, and that the axis allows
// the list anyway is a matter of one shape for both rather than a licence.
type Component struct {
Lang []string
Stack []string
}
// Assemble picks the layers of a topic a component takes, in the order they go
// into a copy: base, then language, then stack.
//
// A layer is chosen by the axis keys of its front matter rather than by where
// its file lies (META-38). A layer with no keys is the base one and travels
// always; a key the layer does not declare puts no demand on the component, so
// a language layer with no stack key fits any stack.
//
// This is the read-only half of assembly. The project commands write files out
// of the same selection, so the two must never come to differ — which is why
// the selection lives here rather than inside whichever command needs it.
func (s *Suite) Assemble(topic string, c Component) (taken, left []*doc.Document) {
for _, d := range s.Layers(topic) {
if fits(d, c) {
taken = append(taken, d)
continue
}
left = append(left, d)
}
return taken, left
}
// fits reports whether a component takes a layer.
func fits(d *doc.Document, c Component) bool {
if d.Front.Lang != "" && !slices.Contains(c.Lang, d.Front.Lang) {
return false
}
if d.Front.Stack != "" && !slices.Contains(c.Stack, d.Front.Stack) {
return false
}
return true
}
// rank orders the layers of a topic the way a copy carries them. A layer on
// both axes comes last: it narrows the most.
func rank(d *doc.Document) int {
switch {
case d.Front.Lang == "" && d.Front.Stack == "":
return 0
case d.Front.Stack == "":
return 1
case d.Front.Lang == "":
return 2
}
return 3
}
// Axis describes a layer in one word, for a report.
func Axis(d *doc.Document) string {
switch {
case d.Front.Lang != "" && d.Front.Stack != "":
return "lang=" + d.Front.Lang + " stack=" + d.Front.Stack
case d.Front.Lang != "":
return "lang=" + d.Front.Lang
case d.Front.Stack != "":
return "stack=" + d.Front.Stack
}
return "base"
}
+186
View File
@@ -0,0 +1,186 @@
// Package suite assembles a conventions suite in memory: its manifest, the
// vocabulary of its language, and the documents the language employs.
//
// What is subject to checking is everything the language employs: the
// convention files and the document the suite governs itself by. The list of
// those files comes from the manifest, from the section of live prefixes where
// every prefix carries a path. A file the language merely quotes — the
// description of the language itself — is not listed in the suite and gets no
// checks.
package suite
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// Suite is a loaded suite.
type Suite struct {
Root string
Manifest *manifest.Manifest
Vocab lang.Vocabulary
// Docs holds the documents of the suite in the order of live prefixes.
Docs []*doc.Document
// ByPrefix maps a manifest prefix to its document.
ByPrefix map[string]*doc.Document
// Missing lists the prefixes whose file the manifest declares while the
// file system holds none.
Missing map[string]string
// Unregistered lists files found in the suite carrying front matter that
// the manifest does not declare. Such a file is neither checked nor
// assembled: for the suite it does not exist, however its author sees it.
Unregistered []string
// Broken lists files whose front matter could not be parsed.
Broken []error
}
// Load reads a suite from directory root.
func Load(root string) (*Suite, error) {
m, err := manifest.Load(root)
if err != nil {
return nil, err
}
vocab, err := lang.Lookup(m.Language.Version, m.Language.Lang)
if err != nil {
return nil, fmt.Errorf("%s: %w", m.Path, err)
}
s := &Suite{
Root: root,
Manifest: m,
Vocab: vocab,
ByPrefix: make(map[string]*doc.Document),
Missing: make(map[string]string),
}
for _, prefix := range m.LivePrefixes() {
rel := filepath.ToSlash(m.Prefixes.Live[prefix])
name := filepath.Join(root, filepath.FromSlash(rel))
d, err := doc.Load(rel, name)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
s.Missing[prefix] = rel
continue
}
s.Broken = append(s.Broken, err)
continue
}
d.Blocks(vocab)
s.Docs = append(s.Docs, d)
s.ByPrefix[prefix] = d
}
if err := s.findUnregistered(); err != nil {
return nil, err
}
return s, nil
}
// findUnregistered walks the suite looking for files written in the conventions
// language yet absent from the manifest. The marker is the prefix key in the
// front matter rather than the location of the file: the suite rearranges its
// taxonomy, while the front matter asserts.
func (s *Suite) findUnregistered() error {
// Only live prefixes declare a path. A retired entry records why a prefix
// left and when, not where a file lies — retiring a prefix means the file
// went with it, so one left behind is undeclared and has to say so.
declared := make(map[string]bool)
for _, path := range s.Manifest.Prefixes.Live {
declared[filepath.ToSlash(path)] = true
}
err := filepath.WalkDir(s.Root, func(name string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
if strings.HasPrefix(entry.Name(), ".") && name != s.Root {
return fs.SkipDir
}
return nil
}
if filepath.Ext(entry.Name()) != ".md" {
return nil
}
rel, err := filepath.Rel(s.Root, name)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if declared[rel] {
return nil
}
d, err := doc.Load(rel, name)
if err != nil {
// The file carries no front matter, or it is broken — for the
// suite this is not a document of the language but plain
// markdown lying nearby.
return nil
}
if d.Front.Prefix != "" {
s.Unregistered = append(s.Unregistered, rel)
}
return nil
})
if err != nil {
return fmt.Errorf("walking the suite: %w", err)
}
sort.Strings(s.Unregistered)
return nil
}
// Conventions picks out the convention documents — the ones that carry a topic
// and therefore travel to a consumer. A document without a topic, the one the
// suite governs itself by, gets no spread checks.
func (s *Suite) Conventions() []*doc.Document {
var out []*doc.Document
for _, d := range s.Docs {
if d.Front.Topic != "" {
out = append(out, d)
}
}
return out
}
// Layers lists the layers of a topic — the documents that declared that name in
// their front matter — in the order a copy carries them: base, then language,
// then stack. Documents are loaded in the order of their prefixes, which says
// nothing about layers, and a listing that opens with a language layer reads as
// if the base one were missing.
func (s *Suite) Layers(topic string) []*doc.Document {
var out []*doc.Document
for _, d := range s.Docs {
if d.Front.Topic == topic {
out = append(out, d)
}
}
sort.Slice(out, func(i, j int) bool {
if rank(out[i]) != rank(out[j]) {
return rank(out[i]) < rank(out[j])
}
return out[i].Path < out[j].Path
})
return out
}
// Prefix returns the prefix the manifest declares for a document.
func (s *Suite) Prefix(d *doc.Document) string {
prefix, _ := s.Manifest.PrefixOf(d.Path)
return prefix
}
// Exists reports whether the suite holds a file at a path from its root.
func (s *Suite) Exists(rel string) bool {
_, err := os.Stat(filepath.Join(s.Root, filepath.FromSlash(rel)))
return err == nil
}
+13
View File
@@ -0,0 +1,13 @@
// Command convy tends development conventions: it checks the integrity of a
// suite and assembles copies inside projects.
package main
import (
"os"
"git.vakhrushev.me/av/convy/internal/cli"
)
func main() {
os.Exit(cli.Main())
}