Compare commits

..
4 Commits
Author SHA1 Message Date
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
40 changed files with 3945 additions and 487 deletions
+91 -13
View File
@@ -33,9 +33,11 @@ CLI для управления конвенциями. Модель здесь
```
internal/lang словарь: реестр «версия языка × естественный язык»
internal/manifest suite.toml — чтение и текстовая правка
internal/source ссылки между уровнями: путь на диске, git-репозиторий
internal/manifest оба манифеста: чтение и запись
internal/doc разбор документа: шапка, области правил, блоки
internal/suite сборка набора в память, отбор слоёв под компонент
internal/project сборка копий в проекте: разделы, маркер, READING.md
internal/check проверки: manifest, form, spread, links
internal/cli команды, диалог, два режима
```
@@ -57,16 +59,30 @@ internal/cli команды, диалог, два режима
- **Что инструмент пишет, инструмент принимает.** Набор, созданный `suite init`,
`add` и `rule`, обязан проходить `suite check` без правок. Это проверяет
`checkClean` в `internal/cli`; ломать инвариант нельзя.
- **Манифест правится текстом, а не энкодером.** В `suite.toml` комментариев
больше, чем данных. `manifest.AddEntry` вставляет запись, подстраиваясь под
порядок таблицы: отсортированную по алфавиту держит отсортированной,
упорядоченную вручную дополняет в конец. Комментарий, отделённый пустой
строкой, принадлежит таблице **ниже** себя.
- **Манифест — данные.** Оба манифеста декодируются в структуры и пишутся
обратно энкодером целиком. Комментариев в них нет: файл, который машина
переписывает, комментарий через круг не проносит, и вид, что проносит, стоит
этого комментария в день, когда никто не смотрит. Объяснения — в соседних
файлах, которых ни одна команда не касается.
- **Непонятый ключ останавливает запись.** Раз запись идёт из структур, ключ,
которого в них нет, при сохранении исчез бы. `manifest.save` отказывается,
называя ключ: это единственный исход, который его не теряет и не прячет.
- **Разбор опирается на разметку, а не на суждение.** Область правила — от
заголовка до следующего заголовка любого уровня. Метка открывает блок только
первой в абзаце и полужирным. Огороженные блоки кода исключаются везде;
инлайн-код вырезается там, где ищутся ссылки, и не вырезается там, где ищутся
пути канона.
пути канона. Маркер локальной части — то же самое: `doc.LocalMarker` один на
весь инструмент, `doc.Marker()` пропускает огороженные блоки, потому что
конвенция о ведении копий этот маркер цитирует.
- **Уровень называется ссылкой, а не путём.** Проект ссылается на набор, набор
на язык; `source.Ref` разбирает ссылку, `source.Open` отдаёт директорию,
которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево:
третий (rclone, дерево по https) ожидается, и отказ обязан сначала сказать,
чем ссылку сочли, и только потом — что не так.
- **Проверки формы не знают про набор.** `checkRules`, `checkVersionLine`,
`checkModalsOutside` и прочие принимают `lang.Vocabulary`, а не `*suite.Suite`
— иначе `convy check` в проекте пришлось бы писать заново. Копия несёт язык
строкой о версии, и `lang.Recognize` читает его оттуда: манифеста рядом нет.
## Решения, которые уже приняты
@@ -84,6 +100,42 @@ internal/cli команды, диалог, два режима
заголовков пропускаются.
- **Лок-файла, `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`.
## Проверки
@@ -97,7 +149,8 @@ internal/cli команды, диалог, два режима
Новая проверка заводится вместе с двумя тестами: что она срабатывает и что она
**молчит** там, где не должна. Второй важнее: проверка, краснеющая на исправном
файле, выключается целиком. Ложные срабатывания собраны в
`TestNoFalsePositives`.
`TestNoFalsePositives` для набора и в `TestCopiesAreSilentOnASoundCopy` для
копий.
Перед тем как заводить проверку, стоит прогнать её замысел по живому канону
(`dev-conventions`): если она покраснеет на исправном наборе, замысел неверен.
@@ -111,6 +164,22 @@ internal/cli команды, диалог, два режима
файл набора; упоминание `README.md` в конвенции она пометит ошибочно. На
текущем каноне не срабатывает.
- Машиночитаемого вывода находок (`--json`) нет.
- Предупреждения о висячей ссылке на неподписанную тему нет: `convy check` до
набора не дотягивается и разрешает только ссылки на префиксы самого файла.
Открытый вопрос из `TOOL.md`; закрывается отдельной командой, а не этой.
- Набор в поддиректории git-репозитория не адресуется: `#рев` есть, `//путь`
нет. Появится вместе с первым набором, который так лежит.
- Источник у проекта один. Модель нескольких допускает; форма `source = "..."`
расширяется до `[sources.имя]` не ломая существующие манифесты.
- `convy add` пишет подписку после сборки — если сборка прошла, а запись в
манифест упала, копия останется неучтённой. Обратный порядок хуже: подписка
без файла отправляет следующий `pull` искать то, чего не делали.
- Директории компонентов сверяются на равенство, а не на вложенность. Компонент
в `docs` и компонент в `docs/sub` манифест пропустит; `convy check` от
двойных находок защищён отдельно (`distinct`).
- `lang.Recognize` при отсутствии словаря с совпавшим номером версии отдаёт
первого кандидата, у которого совпали слова. Пока версия в реестре одна, это
безвредно; со второй версией того же естественного языка станет неверно.
## Тесты
@@ -122,7 +191,12 @@ gofmt -l . && go vet ./... перед коммитом
Тесты фикстурные: набор пишется во временную директорию и прогоняется целиком.
`internal/cli` проверяет обе моды, включая диалог — интерактивный режим иначе не
покрыть, из шелла он требует терминала.
покрыть, из шелла он требует терминала. Проектные тесты строят набор теми же
командами и подключают его в проект: `subscribable` в `project_test.go`
`retirable` плюс `READING.md`, без которого копиям нечего везти рядом.
Git-транспорт проверяется на локальном репозитории через `file://` и
пропускается, если `git` не найден. Сети тесты не требуют.
## Коммиты
@@ -133,9 +207,13 @@ Commits и `Co-Authored-By` не используются.
## Состояние
Наборная сторона закончена: `init`, `add`, `rule`, `retire`, `list`, `check`.
Проектные команды (`add`, `pull`, `list`, `check` без `suite`) не начаты; отбор
слоёв под компонент для них уже написан — `suite.Assemble`, — и переписывать его
в сборщике не нужно.
Наборная сторона: `init`, `add`, `rule`, `retire`, `list`, `check`.
Проектная: `init`, `add`, `pull`, `sync`, `list`, `check`. Обе стороны
закончены по тому, что намечено в `TOOL.md`; `sync` в `TOOL.md` не значится и
заведён сверх него.
Отбор слоёв под компонент — один на обе стороны: `suite.Assemble`. `suite list`
показывает, что взял бы компонент, `convy pull` и `convy sync` то же самое
пишут в файл; разъехаться они не должны.
Линтеров и CI нет.
+168 -7
View File
@@ -12,7 +12,9 @@ CLI для управления конвенциями разработки: в
| Уровень | Что это |
|---|---|
| набор | репозиторий с конвенциями, манифестом `suite.toml` и обвязкой |
| язык | как записывается правило: слова, версия, `READING.md` |
| набор | репозиторий с конвенциями, манифестом `.conventions-suite.toml` и обвязкой |
| проект | репозиторий-потребитель с манифестом `.conventions.toml` |
| тема | набор правил об одном фокусе разработки; единица подписки |
| слой | один файл темы: базовый, языковой или стековый |
| компонент | адресат сборки в проекте: один язык, один стек, один вид приложения |
@@ -21,6 +23,37 @@ CLI для управления конвенциями разработки: в
Тему и ось слоя объявляет шапка файла, а не путь: `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`), сборка обычная:
@@ -48,14 +81,16 @@ go build -o convy .
convy suite list что в наборе и что возьмёт компонент
convy suite check целостность набора: префиксы, темы, оси, ссылки, форма
В проекте (пока не реализовано):
В проекте:
convy init подключить конвенции: источник и первый компонент
convy add <тема> подписаться и собрать
convy pull пересобрать подписанное
convy list что подключено и что доступно
convy pull пересобрать подписанное, текст и всё
convy sync привести файлы в соответствие манифесту
convy list что подключено и что ещё есть в наборе
convy check проверить форму того, что здесь
```
Контекст определяется по манифесту рядом: `suite.toml` — набор,
Контекст определяется по манифесту рядом: `.conventions-suite.toml` — набор,
`.conventions.toml` — проект. Наугад не делается ничего: команда не того уровня
отказывает и подсказывает нужную.
@@ -118,11 +153,133 @@ errors: 1, warnings: 0
Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или
не в том контексте.
## Подключение в проект
```
$ 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
```
Язык копии определяется по строке о версии, которую она несёт: манифест рядом
с ней не лежит, и больше сказать некому.
## Ступени и словарь
Слова, которыми записаны модальность и метки, — свойство версии языка и
естественного языка набора, а не самого набора. Инструмент знает их сам, и
`suite.toml` их не дублирует: достаточно `[language] version` и `lang`.
`.conventions-suite.toml` их не дублирует: хватает `[language] version` и
`lang`.
Поэтому ступень называется категорией, а не словом:
@@ -144,4 +301,8 @@ errors: 1, warnings: 0
git;
- не хранит список подписчиков: подписка — свойство проекта;
- не переносит правки из проекта в набор: операция ручная и редкая;
- не перенумеровывает правила: номер — идентификатор, а не позиция.
- не перенумеровывает правила: номер — идентификатор, а не позиция;
- не кэширует источник: клон делается заново и удаляется;
- не знает нескольких наборов сразу: `source` в проекте один;
- не хранит комментарии в манифестах: они данные, а объяснения — в соседних
файлах.
+11 -11
View File
@@ -74,7 +74,7 @@ type files map[string]string
func base() files {
return files{
"suite.toml": baseManifest,
".conventions-suite.toml": baseManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": reading,
"conventions/time.md": baseTime,
@@ -270,14 +270,14 @@ func TestChecks(t *testing.T) {
}, {
name: "topic listed among the retired ones",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
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["suite.toml"] = strings.Replace(baseManifest,
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`time = "время: хранение, зоны, форматы"`,
`time = "время"`+"\nlogging = \"логирование\"", 1)
},
@@ -285,7 +285,7 @@ func TestChecks(t *testing.T) {
}, {
name: "declared file is missing",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1)
},
@@ -299,7 +299,7 @@ func TestChecks(t *testing.T) {
}, {
name: "prefix starts with X",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
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")
},
@@ -307,7 +307,7 @@ func TestChecks(t *testing.T) {
}, {
name: "one file with two prefixes declared",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1)
},
@@ -315,7 +315,7 @@ func TestChecks(t *testing.T) {
}, {
name: "unknown manifest key",
setup: func(f files) {
f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
f[".conventions-suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
},
want: "is unknown to the tool",
}, {
@@ -362,21 +362,21 @@ func TestChecks(t *testing.T) {
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["suite.toml"] = strings.Replace(baseManifest, "time =", `"время" =`, 1)
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["suite.toml"] = strings.Replace(baseManifest, "time =", "Time_Zone =", 1)
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 second document without a topic",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1)
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
@@ -392,7 +392,7 @@ func TestChecks(t *testing.T) {
}, {
name: "document without a topic carries layer keys",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1)
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
+176
View File
@@ -0,0 +1,176 @@
package check
import (
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
)
// 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
}
+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())
})
}
}
+27 -24
View File
@@ -20,11 +20,11 @@ func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
checkHeadings(d, prefix, rep)
checkHeadingHierarchy(d, rep)
checkNumbering(d, prefix, rep)
checkRules(s, d, rep)
versionFrom, versionTo := checkVersionLine(s, d, rep)
checkModalsOutside(s, d, versionFrom, versionTo, rep)
checkForeignVocabulary(s, d, rep)
checkForeignConnectives(s, d, 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
@@ -62,8 +62,8 @@ func checkHeadingHierarchy(d *doc.Document, rep *Report) {
// 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(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.ForeignConnectives(s.Manifest.Language.Version, s.Manifest.Language.Lang)
func checkForeignConnectives(v lang.Vocabulary, d *doc.Document, rep *Report) {
foreign := lang.ForeignConnectives(v.Version, v.Code)
if len(foreign) == 0 {
return
}
@@ -80,8 +80,8 @@ func checkForeignConnectives(s *suite.Suite, d *doc.Document, rep *Report) {
continue
}
rep.Errorf(Form, d.Path, n,
"the scenario block opens with %s from the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
"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
@@ -142,10 +142,14 @@ func checkHeadings(d *doc.Document, prefix string, rep *Report) {
// 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 {
@@ -173,7 +177,7 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) {
}
}
if len(gaps) > 0 {
rep.Errorf(Form, d.Path, d.Rules[0].Line,
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))
}
@@ -184,8 +188,7 @@ 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(s *suite.Suite, d *doc.Document, rep *Report) {
v := s.Vocab
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)
@@ -255,18 +258,18 @@ func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
// 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(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(s, d)
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(s.Manifest.Language.Version)
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 declared by the suite manifest", version)
"the language version line does not name version %s, the version this document is read by", version)
}
return p.Start, p.End
}
@@ -275,9 +278,9 @@ func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to in
// 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(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
func versionParagraph(v lang.Vocabulary, d *doc.Document) (doc.Paragraph, bool) {
from, to := d.Preamble()
words := s.Vocab.Words()
words := v.Words()
for _, p := range d.Paragraphs(from, to) {
if containsAll(p.Text(), words) {
return p, true
@@ -289,8 +292,8 @@ func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
// 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(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(s.Vocab)
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
@@ -310,8 +313,8 @@ func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo
// 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(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang)
func checkForeignVocabulary(v lang.Vocabulary, d *doc.Document, rep *Report) {
foreign := lang.Foreign(v.Version, v.Code)
if len(foreign) == 0 {
return
}
@@ -325,8 +328,8 @@ func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
for _, w := range words {
if containsWord(text, w) {
rep.Errorf(Form, d.Path, n,
"the word %s belongs to the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
"the word %s belongs to the %q vocabulary, while the document is written in %q",
w, foreign[w], v.Code)
}
}
return true
+3 -3
View File
@@ -93,7 +93,7 @@ prefix: SLOG
func layered() files {
return files{
"suite.toml": layeredManifest,
".conventions-suite.toml": layeredManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": reading,
"conventions/arch/time.md": archTime,
@@ -136,7 +136,7 @@ func TestLayeredChecks(t *testing.T) {
}, {
name: "topic with two layers lacking axis keys",
setup: func(f files) {
f["suite.toml"] = strings.Replace(layeredManifest,
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"] = ""
@@ -273,7 +273,7 @@ func TestNoFalsePositives(t *testing.T) {
}, {
name: "the single document without a topic is the one the suite governs itself by",
setup: func(f files) {
f["suite.toml"] = strings.Replace(layeredManifest,
f[".conventions-suite.toml"] = strings.Replace(layeredManifest,
`SLOG = "conventions/arch/logging.md"`,
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1)
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
+1 -1
View File
@@ -155,7 +155,7 @@ func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
if word == "" {
return
}
version, hasVersion := versionParagraph(s, d)
version, hasVersion := versionParagraph(s.Vocab, d)
d.Prose(func(n int, text string) bool {
if hasVersion && n >= version.Start && n <= version.End {
return true
+28
View File
@@ -8,6 +8,7 @@ import (
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
@@ -15,6 +16,7 @@ import (
func Suite(s *suite.Suite) *Report {
rep := &Report{}
checkManifest(s, rep)
checkLanguageSource(s, rep)
checkTopicNames(s, rep)
checkBaseLayers(s, rep)
checkSelfGoverning(s, rep)
@@ -39,11 +41,17 @@ func checkManifest(s *suite.Suite, rep *Report) {
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() {
@@ -164,6 +172,12 @@ func checkSelfGoverning(s *suite.Suite, rep *Report) {
// 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.
@@ -218,3 +232,17 @@ func sortedKeys[V any](m map[string]V) []string {
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)
}
}
+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, "; ") + "."
}
+155
View File
@@ -0,0 +1,155 @@
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")
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)
}
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)
}
}
+44 -7
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"os"
"strings"
)
// ExitCode is the exit status of the process.
@@ -48,9 +49,18 @@ func Run(env Env, args []string) ExitCode {
switch args[0] {
case "suite":
return runSuite(env, args[1:])
case "add", "pull", "list", "check":
fmt.Fprintf(env.Err, "the %q command is not implemented yet\n", args[0])
return Usage
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
@@ -90,10 +100,12 @@ func usage(w io.Writer) {
fmt.Fprint(w, `convy tending development conventions.
In a project:
convy add <topic> subscribe and assemble (not implemented)
convy pull reassemble what is subscribed (not implemented)
convy list what is wired up and available (not implemented)
convy check check the form of what is here (not implemented)
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
@@ -110,6 +122,31 @@ 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()
+1 -1
View File
@@ -127,7 +127,7 @@ func TestAddInInteractiveMode(t *testing.T) {
t.Errorf("the file lost %q:\n%s", want, body)
}
}
if !strings.Contains(read(t, root, "suite.toml"), `logging = "логирование: уровни"`) {
if !strings.Contains(read(t, root, ".conventions-suite.toml"), `logging = "логирование: уровни"`) {
t.Error("the topic did not reach the manifest")
}
checkClean(t, root)
+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, " ") + ")"
}
+180
View File
@@ -0,0 +1,180 @@
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 directory.
// 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.
func distinctDirs(m *manifest.Project) error {
seen := make(map[string]string)
for _, name := range m.Names() {
dir := filepath.ToSlash(filepath.Clean(m.Components[name].Dir))
if other, taken := seen[dir]; taken {
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", other, name, dir)
}
seen[dir] = name
}
return nil
}
// 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)
}
}
+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 ""
}
+2 -2
View File
@@ -144,7 +144,7 @@ func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) {
t.Error("the file stayed behind")
}
toml := read(t, root, "suite.toml")
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)
}
@@ -179,7 +179,7 @@ func TestRetireTopicWaitsForItsLayers(t *testing.T) {
t.Fatalf("retiring an empty topic returned %d: %s", code, out)
}
toml := read(t, root, "suite.toml")
toml := read(t, root, ".conventions-suite.toml")
if strings.Contains(toml, `time = "время"`) {
t.Errorf("the topic stayed in the live half:\n%s", toml)
}
+4 -17
View File
@@ -252,27 +252,14 @@ func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode
return Usage
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if !s.Manifest.TopicLive(given["topic"]) {
source, err = manifest.AddEntry(source, "topics.live", given["topic"], given["about"])
if err != nil {
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
}
}
source, err = manifest.AddEntry(source, "prefixes.live", given["prefix"], rel)
if err != nil {
fmt.Fprintf(env.Err, "%s was written, but the manifest was not: %s\n", rel, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\ncreated %s\n", rel)
fmt.Fprintf(env.Out, "updated %s: prefix %s", manifest.Name, given["prefix"])
+28 -15
View File
@@ -55,21 +55,7 @@ func runSuiteCheck(env Env, args []string) ExitCode {
func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
findings := rep.Findings()
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)
}
printFindings(w, findings)
if quiet {
return
@@ -81,6 +67,12 @@ func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
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())
@@ -98,3 +90,24 @@ func plural(n int, noun string) string {
}
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)
}
}
+59 -39
View File
@@ -10,52 +10,56 @@ import (
"git.vakhrushev.me/av/convy/internal/manifest"
)
// manifestSkeleton is what a suite starts as. The tables stand empty but
// present: `convy suite add` splices entries into them, and a table that is not
// there is a table an edit cannot find.
// 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 documents about the language are left commented out on purpose. The
// suite is expected to carry them, but the tool cannot author them, and a
// manifest pointing at a file that does not exist is a manifest that fails its
// own check on the first run.
const manifestSkeleton = `# The manifest of a conventions suite.
#
# Two manifests exist in the model, each named after what it describes:
# suite.toml here, in the suite, describes the suite itself; .conventions.toml
# in a project describes what that project subscribed to. Which of the two lies
# next to you tells you where you are.
// 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
[language]
version = %d
lang = "%s"
# description = "LANGUAGE.md" # the full account of the language, stays with the author
# reading = "READING.md" # the short guide for a reader, travels into every copy
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.
# Topics
#
# A topic is a set of rules about one focus of development, and the unit of
# subscription. The value is the one line about what the topic is for; the
# table of conventions in a consumer's README is built out of it.
## The manifest
[topics.live]
` + "`.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.
# Retired names land here together with a reason and a date, so that they can
# never be handed to another topic: the name lives on in foreign repositories.
## Topics
[topics.retired]
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.
# Rule prefixes
#
# A prefix is four uppercase Latin letters, unique across the suite, chosen for
# a file rather than derived by a formula. The letter X is reserved for the
# local rules of consumers and is never taken here. Paths are given from the
# root of the repository.
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.live]
## Prefixes
# Prefixes of deleted and split files land here, likewise never to be reissued.
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.
[prefixes.retired]
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 {
@@ -108,17 +112,33 @@ func runSuiteInit(env Env, args []string) ExitCode {
return Usage
}
body := fmt.Sprintf(manifestSkeleton, *version, given["lang"])
name := filepath.Join(root, manifest.Name)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
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
+2 -2
View File
@@ -22,7 +22,7 @@ func runSuiteList(env Env, args []string) ExitCode {
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, 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
@@ -60,7 +60,7 @@ func runSuiteList(env Env, args []string) ExitCode {
}
selecting := *langAxis != "" || *stackAxis != ""
component := suite.Component{Lang: *langAxis, Stack: *stackAxis}
component := suite.Component{Lang: split(*langAxis), Stack: split(*stackAxis)}
for i, name := range topics {
if i > 0 {
fmt.Fprintln(env.Out)
+4 -33
View File
@@ -12,7 +12,6 @@ import (
"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"
)
@@ -260,23 +259,9 @@ func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string,
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "prefixes.live", prefix)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
source, err = manifest.AddEntry(source, "prefixes.retired", prefix, note)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
s.Manifest.Prefixes.Retire(prefix, note)
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
@@ -314,22 +299,8 @@ func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string,
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "topics.live", topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.AddEntry(source, "topics.retired", topic, when+": "+given["reason"])
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
s.Manifest.Topics.Retire(topic, when+": "+given["reason"])
if err := s.Manifest.Save(); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
+215
View File
@@ -0,0 +1,215 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"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)
}
return changed, left, OK
}
// 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
}
+166
View File
@@ -0,0 +1,166 @@
package cli_test
import (
"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
}
+43
View File
@@ -378,3 +378,46 @@ func StripInline(line string) 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")
}
+7
View File
@@ -18,6 +18,11 @@ type Front struct {
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.
@@ -75,6 +80,8 @@ func parseFront(lines []string) (Front, int, error) {
front.Stack = value
case "extends":
front.Extends = value
case "origin":
front.Origin = value
default:
front.Unknown = append(front.Unknown, key)
}
+75
View File
@@ -292,6 +292,81 @@ func Lookup(version int, code string) (Vocabulary, error) {
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. The version number decides between vocabularies naming the same
// words, which is what two versions of one natural language would do.
func Recognize(text string) (Vocabulary, bool) {
var best Vocabulary
for _, version := range sortedVersions() {
for _, code := range sortedCodes(version) {
v := registry[version][code]
if !namesAll(text, v.Words()) {
continue
}
if best.Version == 0 || matchesVersion(text, v.Version) {
best = v
}
}
}
return best, best.Version != 0
}
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.
+28
View File
@@ -72,3 +72,31 @@ func TestUnknownVersionAndCode(t *testing.T) {
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")
}
}
-152
View File
@@ -1,152 +0,0 @@
package manifest
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
)
// The manifest is edited as text rather than decoded and written back.
//
// suite.toml carries more comment than data — the reasoning behind every topic
// and every prefix lives there, and an encoder would drop all of it and reorder
// what is left. So an entry is spliced into the source, and everything the
// author wrote around it survives untouched.
var (
tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`)
keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`)
bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// AddEntry splices key = "value" into the given table of a TOML source.
//
// Where the entry lands follows what the table already does: a table whose keys
// are in alphabetical order keeps it, and one ordered by hand — by directory,
// by age, by whatever the author meant — gets the entry appended, because
// guessing at that order would scatter it.
func AddEntry(source []byte, table, key, value string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
if slices.Contains(keys, key) {
return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key)
}
entry := renderEntry(key, value)
insert := insertionPoint(keys, at, key, lines, start, end)
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:insert]...)
out = append(out, entry)
out = append(out, lines[insert:]...)
return []byte(strings.Join(out, "\n")), nil
}
// RemoveEntry drops a key from a table, leaving everything around it alone.
// Together with AddEntry it moves an entry from the live half of a section to
// the retired one, which is the only way a name ever leaves the live half.
func RemoveEntry(source []byte, table, key string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
for i, existing := range keys {
if existing != key {
continue
}
out := make([]string, 0, len(lines)-1)
out = append(out, lines[:at[i]]...)
out = append(out, lines[at[i]+1:]...)
return []byte(strings.Join(out, "\n")), nil
}
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
}
// tableKeys collects the keys of a table together with the line each sits on.
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
for i := from; i < to; i++ {
m := keyRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
keys = append(keys, strings.Trim(m[1], `"`))
at = append(at, i)
}
return keys, at
}
// insertionPoint picks the line the entry goes before.
func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int {
if len(keys) == 0 {
// An empty table owns the comments standing right under its header
// and nothing further: a comment block separated by a blank line
// belongs to the table header below it, not to this one. Walking to
// the end of the section instead would file the entry under the wrong
// explanation.
i := start + 1
for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
i++
}
return i
}
if sort.StringsAreSorted(keys) {
for i, existing := range keys {
if key < existing {
return at[i]
}
}
}
return at[len(at)-1] + 1
}
// renderEntry writes one key-value line, quoting the key when it is not bare.
func renderEntry(key, value string) string {
if !bareRe.MatchString(key) {
key = `"` + escape(key) + `"`
}
return key + ` = "` + escape(value) + `"`
}
func escape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
-130
View File
@@ -1,130 +0,0 @@
package manifest_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/manifest"
)
func TestAddEntryKeepsComments(t *testing.T) {
source := `# The suite manifest.
[language]
version = 1
# Topics
#
# A topic is a set of rules about one focus of development.
[topics.live]
config = "configuration"
time = "time"
[topics.retired]
# Empty. Retired names land here together with a reason and a date.
`
got, err := manifest.AddEntry([]byte(source), "topics.live", "logging", "logging: levels, structure")
if err != nil {
t.Fatal(err)
}
out := string(got)
for _, want := range []string{
"# ─── Topics ───",
"# A topic is a set of rules about one focus of development.",
"# Empty. Retired names land here together with a reason and a date.",
`logging = "logging: levels, structure"`,
} {
if !strings.Contains(out, want) {
t.Errorf("the result lost %q:\n%s", want, out)
}
}
}
// A table whose keys are already sorted keeps its order; one ordered by hand
// gets the entry appended, so that a grouping by directory survives.
func TestAddEntryRespectsExistingOrder(t *testing.T) {
sorted := `[topics.live]
config = "c"
time = "t"
`
got, err := manifest.AddEntry([]byte(sorted), "topics.live", "logging", "l")
if err != nil {
t.Fatal(err)
}
wantSorted := "[topics.live]\nconfig = \"c\"\nlogging = \"l\"\ntime = \"t\"\n"
if string(got) != wantSorted {
t.Errorf("a sorted table was not kept sorted:\n%s", got)
}
grouped := `[prefixes.live]
TIME = "conventions/arch/time.md"
CONF = "conventions/arch/config.md"
GTIM = "conventions/lang/go/time.md"
`
got, err = manifest.AddEntry([]byte(grouped), "prefixes.live", "GCFG", "conventions/lang/go/config.md")
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(strings.TrimRight(string(got), "\n"), `GCFG = "conventions/lang/go/config.md"`) {
t.Errorf("a hand-ordered table did not get the entry appended:\n%s", got)
}
}
func TestAddEntryIntoEmptyTable(t *testing.T) {
source := `[topics.live]
# Nothing yet.
[topics.retired]
`
got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time")
if err != nil {
t.Fatal(err)
}
want := "[topics.live]\n# Nothing yet.\ntime = \"time\"\n\n[topics.retired]\n"
if string(got) != want {
t.Errorf("insertion into an empty table went wrong:\n%q", got)
}
}
// A comment block separated from an empty table by a blank line explains the
// table header standing below it, not the one above. Filing an entry after such
// a block puts it under the wrong explanation.
func TestAddEntryIntoEmptyTableStopsBeforeTheNextComment(t *testing.T) {
source := `[topics.live]
# Retired names land here together with a reason and a date.
[topics.retired]
`
got, err := manifest.AddEntry([]byte(source), "topics.live", "time", "time")
if err != nil {
t.Fatal(err)
}
want := "[topics.live]\ntime = \"time\"\n\n# Retired names land here together with a reason and a date.\n\n[topics.retired]\n"
if string(got) != want {
t.Errorf("the entry was filed under the wrong comment:\n%q", got)
}
}
func TestAddEntryRejectsDuplicateAndMissingTable(t *testing.T) {
source := "[topics.live]\ntime = \"t\"\n"
if _, err := manifest.AddEntry([]byte(source), "topics.live", "time", "t"); err == nil {
t.Error("a duplicate key was accepted")
}
if _, err := manifest.AddEntry([]byte(source), "prefixes.live", "TIME", "x.md"); err == nil {
t.Error("a missing table was accepted")
}
}
func TestAddEntryQuotesWhatIsNotBare(t *testing.T) {
source := "[topics.live]\n"
got, err := manifest.AddEntry([]byte(source), "topics.live", "web ui", `a "quoted" thing`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), `"web ui" = "a \"quoted\" thing"`) {
t.Errorf("key or value was not escaped:\n%s", got)
}
}
+89 -17
View File
@@ -1,12 +1,24 @@
// Package manifest reads suite.toml, the manifest of a conventions suite.
// Package manifest reads and writes the two manifests of the model.
//
// The manifest declares three things: the language the suite's rules are
// 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"
@@ -18,38 +30,66 @@ import (
)
// Name is the name of a suite manifest. Which of the two manifests lies next
// to you tells you where you are: suite.toml means a suite, .conventions.toml
// means a project.
const Name = "suite.toml"
// 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
// and the two documents about it. The full description stays with the author of
// the suite, the short one travels into the copy.
// 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"`
Description string `toml:"description"`
Reading string `toml:"reading"`
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"`
Retired map[string]string `toml:"retired"`
Live map[string]string `toml:"live,omitempty"`
Retired map[string]string `toml:"retired,omitempty"`
}
// Manifest is a parsed suite.toml.
// 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"`
Topics Section `toml:"topics"`
Prefixes Section `toml:"prefixes"`
Topics Section `toml:"topics,omitempty"`
Prefixes Section `toml:"prefixes,omitempty"`
// Path is where the manifest was read from.
Path string `toml:"-"`
@@ -84,15 +124,47 @@ func Load(root string) (*Manifest, error) {
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 {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
+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)
}
}
+17 -8
View File
@@ -1,13 +1,22 @@
package suite
import "git.vakhrushev.me/av/convy/internal/doc"
import (
"slices"
// Component is what a copy is assembled for: one language, one stack, 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.
"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
Lang []string
Stack []string
}
// Assemble picks the layers of a topic a component takes, in the order they go
@@ -34,10 +43,10 @@ func (s *Suite) Assemble(topic string, c Component) (taken, left []*doc.Document
// fits reports whether a component takes a layer.
func fits(d *doc.Document, c Component) bool {
if d.Front.Lang != "" && d.Front.Lang != c.Lang {
if d.Front.Lang != "" && !slices.Contains(c.Lang, d.Front.Lang) {
return false
}
if d.Front.Stack != "" && d.Front.Stack != c.Stack {
if d.Front.Stack != "" && !slices.Contains(c.Stack, d.Front.Stack) {
return false
}
return true