Compare commits
6
Commits
4615de6e86
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
600aba5ee3
|
||
|
|
29d29740f5
|
||
|
|
a597805f3c
|
||
|
|
92bd1f463d
|
||
|
|
b6b0976c19
|
||
|
|
23d88c4048
|
@@ -24,8 +24,11 @@ code in this repository.
|
||||
CLI для управления конвенциями. Модель здесь не описывается: она живёт в
|
||||
репозитории набора `dev-conventions` — `README.md` (набор и копии),
|
||||
`LANGUAGE.md` (форма правила и список проверок), `GUIDE.md` (ведение набора,
|
||||
префикс META), `TOOL.md` (решения об инструменте). При расхождении истина там,
|
||||
а не в коде и не здесь.
|
||||
префикс META). При расхождении истина там, а не в коде и не здесь.
|
||||
|
||||
Решения о самом инструменте живут здесь. Раньше они лежали в `TOOL.md`
|
||||
канона — стартовой точке разработки; она свою задачу выполнила и удалена,
|
||||
а всё, что из неё осталось живым, перенесено ниже.
|
||||
|
||||
Пользовательская сторона — в `README.md`. Ниже — то, что нужно знать, правя код.
|
||||
|
||||
@@ -33,16 +36,22 @@ 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 команды, диалог, два режима
|
||||
```
|
||||
|
||||
Зависимость одна — `BurntSushi/toml`. Вторую не заводить: так решено в
|
||||
`TOOL.md`, и `Undecoded()` этого парсера бесплатно ловит опечатки в ключах
|
||||
манифеста.
|
||||
Зависимость одна — `BurntSushi/toml`, и вторую не заводить. `Undecoded()`
|
||||
этого парсера бесплатно ловит опечатки в ключах манифеста, а инструмент,
|
||||
который копирует файлы и разбирает markdown, не имеет права тянуть за собой
|
||||
дерево чужого кода.
|
||||
|
||||
Бинарь тоже один, с подкомандой `suite`. Разделять на два дешевле, если
|
||||
задачи разъедутся, но пока они не разъехались.
|
||||
|
||||
## Инварианты кода
|
||||
|
||||
@@ -57,16 +66,36 @@ 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) ожидается, и отказ обязан сначала сказать,
|
||||
чем ссылку сочли, и только потом — что не так.
|
||||
- **Документ самоуправления объявлен, а не угадан.** `governance` в манифесте
|
||||
называет файл, который написан языком конвенций, но не принадлежит теме.
|
||||
Угадывание пробовали — «он один» и «у него нет ключей оси» верны и для
|
||||
набора, у которого единственная конвенция потеряла `topic`, а потеря эта
|
||||
дорогая: файл сохраняет все проверки формы и тихо теряет все проверки об
|
||||
отъезде к потребителю.
|
||||
- **Проверки формы не знают про набор.** `checkRules`, `checkVersionLine`,
|
||||
`checkModalsOutside` и прочие принимают `lang.Vocabulary`, а не `*suite.Suite`
|
||||
— иначе `convy check` в проекте пришлось бы писать заново. Копия несёт язык
|
||||
строкой о версии, и `lang.Recognize` читает его оттуда: манифеста рядом нет.
|
||||
|
||||
## Решения, которые уже приняты
|
||||
|
||||
@@ -84,6 +113,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,20 +162,22 @@ internal/cli команды, диалог, два режима
|
||||
Новая проверка заводится вместе с двумя тестами: что она срабатывает и что она
|
||||
**молчит** там, где не должна. Второй важнее: проверка, краснеющая на исправном
|
||||
файле, выключается целиком. Ложные срабатывания собраны в
|
||||
`TestNoFalsePositives`.
|
||||
`TestNoFalsePositives` для набора и в `TestCopiesAreSilentOnASoundCopy` для
|
||||
копий.
|
||||
|
||||
Перед тем как заводить проверку, стоит прогнать её замысел по живому канону
|
||||
(`dev-conventions`): если она покраснеет на исправном наборе, замысел неверен.
|
||||
|
||||
## Известные остатки
|
||||
|
||||
- Набор без документа самоуправления, в котором конвенция потеряла `topic`,
|
||||
проскочит: признаков «этот документ один» и «у него нет ключей слоя» не
|
||||
хватает. Закрывается маркером в манифесте — правка формата, не сделана.
|
||||
- Проверка пути канона считает путём любой токен `*.md`, который резолвится в
|
||||
файл набора; упоминание `README.md` в конвенции она пометит ошибочно. На
|
||||
текущем каноне не срабатывает.
|
||||
- Машиночитаемого вывода находок (`--json`) нет.
|
||||
- Набор в поддиректории git-репозитория не адресуется: `#рев` есть, `//путь`
|
||||
нет. Синтаксис, которого не просит ни один набор, не заводится заранее.
|
||||
- Источник у проекта один. Модель нескольких допускает; форма `source = "..."`
|
||||
расширяется до `[sources.имя]`, не ломая существующие манифесты.
|
||||
- `convy add` пишет подписку после сборки. Если сборка прошла, а запись упала,
|
||||
копия останется неучтённой — но это уже поправимо: `convy sync` её либо
|
||||
уберёт, либо соберёт заново, когда тему подпишут.
|
||||
- Ни линтеров, ни CI. Раздача — `go install`, пока этого хватает.
|
||||
|
||||
## Тесты
|
||||
|
||||
@@ -122,7 +189,12 @@ gofmt -l . && go vet ./... перед коммитом
|
||||
|
||||
Тесты фикстурные: набор пишется во временную директорию и прогоняется целиком.
|
||||
`internal/cli` проверяет обе моды, включая диалог — интерактивный режим иначе не
|
||||
покрыть, из шелла он требует терминала.
|
||||
покрыть, из шелла он требует терминала. Проектные тесты строят набор теми же
|
||||
командами и подключают его в проект: `subscribable` в `project_test.go` —
|
||||
`retirable` плюс `READING.md`, без которого копиям нечего везти рядом.
|
||||
|
||||
Git-транспорт проверяется на локальном репозитории через `file://` и
|
||||
пропускается, если `git` не найден. Сети тесты не требуют.
|
||||
|
||||
## Коммиты
|
||||
|
||||
@@ -133,9 +205,12 @@ 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`. Обе стороны
|
||||
закончены.
|
||||
|
||||
Отбор слоёв под компонент — один на обе стороны: `suite.Assemble`. `suite list`
|
||||
показывает, что взял бы компонент, `convy pull` и `convy sync` то же самое
|
||||
пишут в файл; разъехаться они не должны.
|
||||
|
||||
Линтеров и CI нет.
|
||||
|
||||
@@ -5,14 +5,15 @@ CLI для управления конвенциями разработки: в
|
||||
|
||||
Модель, которую инструмент реализует, описана не здесь, а в репозитории набора
|
||||
(`dev-conventions`): `README.md` — устройство набора и копий, `LANGUAGE.md` —
|
||||
форма правила, `GUIDE.md` — правила ведения набора, `TOOL.md` — решения об этом
|
||||
инструменте. При расхождении истина там.
|
||||
форма правила, `GUIDE.md` — правила ведения набора. При расхождении истина там.
|
||||
|
||||
## Термины
|
||||
|
||||
| Уровень | Что это |
|
||||
|---|---|
|
||||
| набор | репозиторий с конвенциями, манифестом `suite.toml` и обвязкой |
|
||||
| язык | как записывается правило: слова, версия, `READING.md` |
|
||||
| набор | репозиторий с конвенциями, манифестом `.conventions-suite.toml` и обвязкой |
|
||||
| проект | репозиторий-потребитель с манифестом `.conventions.toml` |
|
||||
| тема | набор правил об одном фокусе разработки; единица подписки |
|
||||
| слой | один файл темы: базовый, языковой или стековый |
|
||||
| компонент | адресат сборки в проекте: один язык, один стек, один вид приложения |
|
||||
@@ -21,6 +22,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`), сборка обычная:
|
||||
@@ -35,7 +67,7 @@ go install git.vakhrushev.me/av/convy@latest
|
||||
go build -o convy .
|
||||
```
|
||||
|
||||
Способ раздачи готовых бинарей пока не выбран — вопрос открыт в `TOOL.md`.
|
||||
Готовых бинарей пока нет: `go install` хватает.
|
||||
|
||||
## Команды
|
||||
|
||||
@@ -48,14 +80,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` — проект. Наугад не делается ничего: команда не того уровня
|
||||
отказывает и подсказывает нужную.
|
||||
|
||||
@@ -116,13 +150,146 @@ errors: 1, warnings: 0
|
||||
текста не даётся и остаётся работой читателя.
|
||||
|
||||
Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или
|
||||
не в том контексте.
|
||||
не в том контексте. Обе проверки принимают `--json` — те же находки в том же
|
||||
порядке, для вызывающего, который не человек:
|
||||
|
||||
```json
|
||||
{"findings":[{"severity":"error","family":"spread","path":"docs/conventions/time.md",
|
||||
"line":22,"message":"..."}],"errors":1,"warnings":0}
|
||||
```
|
||||
|
||||
Манифест набора называет ключом `governance` документ, которым набор ведёт
|
||||
себя сам, — тот, что написан языком конвенций, но не принадлежит теме и потому
|
||||
никуда не едет. Без этого ключа конвенция, потерявшая `topic`, была бы от него
|
||||
неотличима.
|
||||
|
||||
## Подключение в проект
|
||||
|
||||
```
|
||||
$ convy init --source ../dev-conventions --component backend \
|
||||
--dir backend/docs/conventions --lang go --stack sqlite
|
||||
$ convy add time
|
||||
$ convy add logging --for backend
|
||||
```
|
||||
|
||||
`init` проверяет источник до того, как писать манифест: набор, до которого
|
||||
никто не доберётся, проходит любую проверку и никому не помогает. Получается
|
||||
`.conventions.toml`:
|
||||
|
||||
```toml
|
||||
source = "../dev-conventions"
|
||||
|
||||
[components.backend]
|
||||
dir = "backend/docs/conventions"
|
||||
lang = ["go"]
|
||||
stack = ["sqlite"]
|
||||
topics = ["logging", "time"]
|
||||
```
|
||||
|
||||
Компонент пишется всегда, даже когда он один; при нескольких команда без
|
||||
`--for` не угадывает, а перечисляет имена. `stack` — список: `sqlite` и
|
||||
`postgres` действуют вместе, это разные таблицы одного сервиса. Два языка не
|
||||
действуют вместе никогда — ради этого компонент и заведён.
|
||||
|
||||
## Манифесты — данные, а не текст
|
||||
|
||||
Оба манифеста инструмент и читает, и переписывает целиком. Поэтому комментариев
|
||||
в них нет: файл, который машина переписывает, комментарий через круг не
|
||||
проносит, а вид, что проносит, стоит этого комментария в день, когда никто не
|
||||
смотрит. Объяснения живут в соседних файлах, которых ни одна команда не
|
||||
касается: `convy suite init` заводит рядом `README.md` и пишет их туда.
|
||||
|
||||
Ключ, которого инструмент не знает, при записи потерялся бы. Поэтому он не
|
||||
пишет вовсе:
|
||||
|
||||
```
|
||||
$ convy suite add --topic time --about "время" --prefix TIME --title "Время"
|
||||
.conventions-suite.toml holds 1 key the tool does not know
|
||||
(language.descriptoin); a write goes out of what the tool understands, so the
|
||||
key would be dropped — fix the spelling first
|
||||
```
|
||||
|
||||
## Манифест — источник истины
|
||||
|
||||
`.conventions.toml` правится руками так же законно, как командой. Дальше
|
||||
раскладку под него подводит `sync`:
|
||||
|
||||
```
|
||||
$ convy sync --dry-run
|
||||
backend → docs/conventions
|
||||
+ docs/conventions/errors.md subscribed, and no file
|
||||
- docs/conventions/logging.md nothing subscribes to "logging"
|
||||
|
||||
2 files would change; run without --dry-run to do it
|
||||
```
|
||||
|
||||
Деление с `pull` проходит по тому, о чём команда. `pull` — о содержимом:
|
||||
берёт текст всех подписок заново, и оставленный им дифф и есть смысл запуска.
|
||||
`sync` — о наборе файлов: чего манифест требует и нет — собирается, что есть и
|
||||
никому не нужно — удаляется.
|
||||
|
||||
Копия с локальной частью не удаляется никогда: ниже маркера лежит
|
||||
единственное, чего нет больше нигде. Такая копия называется в отчёте, и `sync`
|
||||
завершается с ошибкой, пока её не убрали руками или не подписались снова.
|
||||
|
||||
Перед тем как что-то трогать, `sync` сверяет манифест: подписка на снятую или
|
||||
несуществующую тему, тема дважды, два языка в одном компоненте, тема без
|
||||
подходящего слоя, общая директория у двух компонентов. Находки называются
|
||||
разом, и ничего не пишется.
|
||||
|
||||
Копия плоская, файл на тему. Первый слой — сам документ; каждый следующий
|
||||
становится его разделом, и заголовки внутри опускаются на уровень: слой
|
||||
реализует и сужает базу, а не стоит рядом с ней. Строка о версии языка
|
||||
остаётся одна.
|
||||
|
||||
```markdown
|
||||
---
|
||||
origin: time
|
||||
---
|
||||
|
||||
# Время
|
||||
...
|
||||
### TIME-1. Единый формат — RFC 3339, UTC
|
||||
...
|
||||
## Время: реализация на Go
|
||||
...
|
||||
#### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||
...
|
||||
<!-- conv:local -->
|
||||
```
|
||||
|
||||
Всё ниже `<!-- conv:local -->` принадлежит репозиторию и переживает `pull`;
|
||||
всё выше перезаписывается. Рядом с копиями кладётся `READING.md` — он
|
||||
приезжает с уровня языка. `README.md` в той же директории принадлежит проекту
|
||||
и не трогается, а файл, у которого убрали `origin:`, перестаёт быть копией:
|
||||
`pull` его не перезапишет и скажет почему.
|
||||
|
||||
## Проверка проекта
|
||||
|
||||
`convy check` до набора не дотягивается и сети не требует: форма правила одна
|
||||
и та же, локальные правила проекта на `X`-префиксах записаны по ней же.
|
||||
Проверяются шапка `origin:`, маркер локальной части, нумерация по каждому
|
||||
префиксу, блоки правила, словарь — и то, чего в наборе не бывает:
|
||||
|
||||
```
|
||||
$ convy check
|
||||
docs/conventions/time.md
|
||||
error:22 rule XTIM-1 is a rule of the repository standing above the
|
||||
marker: a reassembly would wipe it [spread]
|
||||
|
||||
project: 2 files in 1 component
|
||||
errors: 1, warnings: 0
|
||||
```
|
||||
|
||||
Язык копии определяется по строке о версии, которую она несёт: манифест рядом
|
||||
с ней не лежит, и больше сказать некому.
|
||||
|
||||
## Ступени и словарь
|
||||
|
||||
Слова, которыми записаны модальность и метки, — свойство версии языка и
|
||||
естественного языка набора, а не самого набора. Инструмент знает их сам, и
|
||||
`suite.toml` их не дублирует: достаточно `[language] version` и `lang`.
|
||||
`.conventions-suite.toml` их не дублирует: хватает `[language] version` и
|
||||
`lang`.
|
||||
|
||||
Поэтому ступень называется категорией, а не словом:
|
||||
|
||||
@@ -144,4 +311,8 @@ errors: 1, warnings: 0
|
||||
git;
|
||||
- не хранит список подписчиков: подписка — свойство проекта;
|
||||
- не переносит правки из проекта в набор: операция ручная и редкая;
|
||||
- не перенумеровывает правила: номер — идентификатор, а не позиция.
|
||||
- не перенумеровывает правила: номер — идентификатор, а не позиция;
|
||||
- не кэширует источник: клон делается заново и удаляется;
|
||||
- не знает нескольких наборов сразу: `source` в проекте один;
|
||||
- не хранит комментарии в манифестах: они данные, а объяснения — в соседних
|
||||
файлах.
|
||||
|
||||
@@ -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,27 +362,40 @@ 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",
|
||||
name: "a convention that lost its topic next to the governing document",
|
||||
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[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
|
||||
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
|
||||
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
|
||||
},
|
||||
want: "more than one document without a topic",
|
||||
want: "has lost the key",
|
||||
}, {
|
||||
name: "a convention that lost its topic in a suite with no governing document",
|
||||
setup: func(f files) {
|
||||
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time\n", "", 1)
|
||||
},
|
||||
want: "the manifest names no document the suite governs itself by",
|
||||
}, {
|
||||
name: "the governing document declared with a topic",
|
||||
setup: func(f files) {
|
||||
f[".conventions-suite.toml"] = "governance = \"conventions/time.md\"\n" + baseManifest
|
||||
},
|
||||
want: "belongs to no topic",
|
||||
}, {
|
||||
name: "the short account of the language lost a word of the vocabulary",
|
||||
setup: func(f files) {
|
||||
@@ -390,14 +403,21 @@ func TestChecks(t *testing.T) {
|
||||
},
|
||||
want: "does not name ДОПУСКАЕТСЯ",
|
||||
}, {
|
||||
name: "document without a topic carries layer keys",
|
||||
name: "the governing document 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-suite.toml"] = "governance = \"conventions/go.md\"\n" + f[".conventions-suite.toml"]
|
||||
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
|
||||
},
|
||||
want: "carries the keys of a layer",
|
||||
}, {
|
||||
name: "governance names a file that is not there",
|
||||
setup: func(f files) {
|
||||
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + baseManifest
|
||||
},
|
||||
want: "governance names",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -411,3 +431,31 @@ func TestChecks(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The two names that mean something on the consumer's side as well: a
|
||||
// convention naming either is talking about the copy, not about the suite.
|
||||
func TestCanonPathLeavesTheConsumersOwnNames(t *testing.T) {
|
||||
f := base()
|
||||
f["README.md"] = "# Канон\n"
|
||||
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
|
||||
"Таблица тем собирается в README.md директории конвенций, а как читать\nправило — сказано в READING.md рядом с копиями.\n\n## Правила", 1)
|
||||
|
||||
for _, finding := range run(t, f) {
|
||||
if strings.Contains(finding.Msg, "canon file path") {
|
||||
t.Errorf("a name of the consumer's own was taken for a path of the suite: %s", finding.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// A path of a convention is still a path of a convention.
|
||||
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
|
||||
"Подробности — в conventions/time.md.\n\n## Правила", 1)
|
||||
found := false
|
||||
for _, finding := range run(t, f) {
|
||||
if strings.Contains(finding.Msg, "canon file path") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("a path of a convention went unnoticed:\n%s", messages(run(t, f)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
// Checking a copy is not checking a suite with parts left out. A copy has no
|
||||
// manifest next to it, no prefix of its own and no path back to where it came
|
||||
// from — it declares its language by the version line and its origin by one
|
||||
// key, and that is everything a consuming repository holds.
|
||||
//
|
||||
// So what is checked here is what a copy answers for on its own: the form of a
|
||||
// rule, which is the same form the suite writes, and the two things only a copy
|
||||
// has — the marker of the local part, and the rule that whatever is written
|
||||
// below it takes a prefix on X.
|
||||
|
||||
// CopyMarker is the boundary between what the suite wrote and what the
|
||||
// repository wrote. It is one constant, defined next to the parsing that has to
|
||||
// respect it: two of them would drift apart in silence, and each half of the
|
||||
// tool would then read a different file.
|
||||
const CopyMarker = doc.LocalMarker
|
||||
|
||||
// Copy checks one assembled convention.
|
||||
func Copy(d *doc.Document, rep *Report) {
|
||||
if !checkOrigin(d, rep) {
|
||||
return
|
||||
}
|
||||
v, ok := recognize(d, rep)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d.Blocks(v)
|
||||
|
||||
markers := d.Markers()
|
||||
marker := 0
|
||||
if len(markers) > 0 {
|
||||
marker = markers[0]
|
||||
}
|
||||
checkMarker(d, markers, rep)
|
||||
checkCopyHeadings(d, marker, rep)
|
||||
checkHeadingHierarchy(d, rep)
|
||||
for _, prefix := range prefixes(d) {
|
||||
checkNumbering(d, prefix, rep)
|
||||
}
|
||||
checkRules(v, d, rep)
|
||||
versionFrom, versionTo := checkVersionLine(v, d, rep)
|
||||
checkModalsOutside(v, d, versionFrom, versionTo, rep)
|
||||
checkForeignVocabulary(v, d, rep)
|
||||
checkForeignConnectives(v, d, rep)
|
||||
checkCopyLinks(d, rep)
|
||||
}
|
||||
|
||||
// checkOrigin checks the front matter of a copy: one key, the name of the
|
||||
// topic. The keys of a suite file have no business here — a copy is flat and
|
||||
// carries no axis, and a stray extends would point at a path the repository
|
||||
// does not have.
|
||||
func checkOrigin(d *doc.Document, rep *Report) bool {
|
||||
if !d.Front.Present || d.Front.Origin == "" {
|
||||
rep.Errorf(Spread, d.Path, 1, "the file carries no origin key and is not a copy")
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"topic", "prefix", "lang", "stack", "extends"} {
|
||||
if at, ok := d.Front.At[key]; ok {
|
||||
rep.Errorf(Spread, d.Path, at,
|
||||
"the front matter of a copy carries the key %q of a suite file: a copy declares its topic by origin and nothing else", key)
|
||||
}
|
||||
}
|
||||
for _, key := range d.Front.Unknown {
|
||||
rep.Warnf(Spread, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// recognize works out which vocabulary the copy is written in.
|
||||
func recognize(d *doc.Document, rep *Report) (lang.Vocabulary, bool) {
|
||||
from, to := d.Preamble()
|
||||
var text []string
|
||||
for _, p := range d.Paragraphs(from, to) {
|
||||
text = append(text, p.Text())
|
||||
}
|
||||
v, ok := lang.Recognize(strings.Join(text, "\n"))
|
||||
if !ok {
|
||||
start, _ := d.Preamble()
|
||||
rep.Errorf(Form, d.Path, start,
|
||||
"the introductory prose holds no language version line, and it is the only thing that says which words of this file are normative")
|
||||
return lang.Vocabulary{}, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// checkMarker checks the boundary of the local part. A marker quoted inside a
|
||||
// fenced block is not one — a convention about keeping copies carries such a
|
||||
// quotation — and the parser has already left those out.
|
||||
func checkMarker(d *doc.Document, markers []int, rep *Report) {
|
||||
if len(markers) == 0 {
|
||||
rep.Errorf(Spread, d.Path, d.Len(),
|
||||
"the copy carries no %s marker: there is nowhere to write a derogation, and a reassembly would overwrite whatever was written instead", CopyMarker)
|
||||
return
|
||||
}
|
||||
if len(markers) > 1 {
|
||||
rep.Errorf(Spread, d.Path, markers[1],
|
||||
"the copy carries a second %s marker: the marker is one, and everything below the first belongs to the repository", CopyMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// checkCopyHeadings checks what a rule heading of a copy answers for. The level
|
||||
// is not among it: layers below the first become sections of the document when
|
||||
// assembled, and their rules step down with them.
|
||||
func checkCopyHeadings(d *doc.Document, marker int, rep *Report) {
|
||||
for _, r := range d.Rules {
|
||||
if r.Malformed != "" {
|
||||
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
|
||||
}
|
||||
local := marker > 0 && r.Line > marker
|
||||
switch {
|
||||
case local && !strings.HasPrefix(r.Prefix, "X"):
|
||||
rep.Errorf(Spread, d.Path, r.Line,
|
||||
"rule %s stands below the marker and takes a prefix the suite could hand out: a rule of the repository takes a prefix on X", r.ID())
|
||||
case !local && strings.HasPrefix(r.Prefix, "X"):
|
||||
rep.Errorf(Spread, d.Path, r.Line,
|
||||
"rule %s is a rule of the repository standing above the marker: a reassembly would wipe it", r.ID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prefixes lists the prefixes the rules of a copy use, in a stable order. There
|
||||
// is more than one: a copy gathers the layers of a topic, and a layer brings its
|
||||
// own prefix along.
|
||||
func prefixes(d *doc.Document) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, r := range d.Rules {
|
||||
if !seen[r.Prefix] {
|
||||
seen[r.Prefix] = true
|
||||
out = append(out, r.Prefix)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkCopyLinks resolves the references a copy can resolve: the ones to a
|
||||
// prefix the file itself holds. A reference to another topic is left alone — the
|
||||
// repository may well not be subscribed to it, and that is legitimate (META-20).
|
||||
func checkCopyLinks(d *doc.Document, rep *Report) {
|
||||
own := make(map[string]map[int]bool)
|
||||
for _, r := range d.Rules {
|
||||
if own[r.Prefix] == nil {
|
||||
own[r.Prefix] = make(map[int]bool)
|
||||
}
|
||||
own[r.Prefix][r.Num] = true
|
||||
}
|
||||
|
||||
for _, ref := range refsIn(d, d.Body, d.Len()) {
|
||||
nums, mine := own[ref.Prefix]
|
||||
if !mine || nums[ref.Num] {
|
||||
continue
|
||||
}
|
||||
rep.Errorf(Links, d.Path, ref.Line,
|
||||
"reference %s points at a rule this file does not hold, while it does hold the rules of %s",
|
||||
ref.Text, ref.Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Copies checks every copy handed to it.
|
||||
func Copies(docs []*doc.Document) *Report {
|
||||
rep := &Report{}
|
||||
for _, d := range docs {
|
||||
Copy(d, rep)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// Dangling lists the references a copy makes to rules of topics the component
|
||||
// did not take. Such a reference resolves nowhere for its reader: the rule it
|
||||
// names exists, but not in this repository.
|
||||
//
|
||||
// It is not an error. META-20 allows a convention to name a rule of another
|
||||
// topic outside the norm, and a rationale that lost its addressee degrades
|
||||
// honestly — the reader loses a pointer rather than the requirement. So this is
|
||||
// something to look at, and it lives here rather than in Copy because it needs
|
||||
// the suite, which a check of copies deliberately does not reach.
|
||||
func Dangling(d *doc.Document, s *suite.Suite, subscribed func(topic string) bool) []Ref {
|
||||
own := make(map[string]bool)
|
||||
for _, r := range d.Rules {
|
||||
own[r.Prefix] = true
|
||||
}
|
||||
|
||||
var out []Ref
|
||||
seen := make(map[string]bool)
|
||||
for _, ref := range refsIn(d, d.Body, d.Len()) {
|
||||
if own[ref.Prefix] || strings.HasPrefix(ref.Prefix, "X") || seen[ref.Prefix] {
|
||||
continue
|
||||
}
|
||||
target, ok := s.ByPrefix[ref.Prefix]
|
||||
if !ok || target.Front.Topic == "" || subscribed(target.Front.Topic) {
|
||||
continue
|
||||
}
|
||||
seen[ref.Prefix] = true
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TopicOf names the topic a prefix belongs to.
|
||||
func TopicOf(s *suite.Suite, prefix string) string {
|
||||
if d, ok := s.ByPrefix[prefix]; ok {
|
||||
return d.Front.Topic
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,9 +273,10 @@ 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[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
|
||||
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
@@ -104,3 +105,34 @@ func (r *Report) Errors() int {
|
||||
func (r *Report) Warnings() int {
|
||||
return len(r.findings) - r.Errors()
|
||||
}
|
||||
|
||||
// MarshalJSON writes a finding the way a machine reads it: the severity and the
|
||||
// family as words rather than as the numbers they happen to be inside.
|
||||
func (f Finding) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Severity string `json:"severity"`
|
||||
Family string `json:"family"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{f.Severity.String(), string(f.Family), f.Path, f.Line, f.Msg})
|
||||
}
|
||||
|
||||
// JSON renders the report for a caller that is not a person. Findings come out
|
||||
// in the order they are printed in, so the two outputs never disagree about
|
||||
// what was found first.
|
||||
func (r *Report) JSON() ([]byte, error) {
|
||||
out := struct {
|
||||
Findings []Finding `json:"findings"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
}{r.Findings(), r.Errors(), r.Warnings()}
|
||||
if out.Findings == nil {
|
||||
out.Findings = []Finding{}
|
||||
}
|
||||
body, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(body, '\n'), nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
@@ -135,6 +136,14 @@ func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Docume
|
||||
// the suite holds. Unlike an extends key it needs no single answer: a path that
|
||||
// fits several files of the canon is a path all the same.
|
||||
func namesSuiteFile(s *suite.Suite, candidate string) bool {
|
||||
// Two names mean something on the consumer's side as well, and a
|
||||
// convention naming either of them is talking about the copy rather than
|
||||
// about the suite: README.md belongs to the consuming repository, and
|
||||
// READING.md is the guide that travels next to the copies.
|
||||
switch path.Base(candidate) {
|
||||
case "README.md", project.ReadingName:
|
||||
return false
|
||||
}
|
||||
if s.Exists(candidate) {
|
||||
return true
|
||||
}
|
||||
@@ -155,7 +164,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
|
||||
|
||||
+55
-18
@@ -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() {
|
||||
@@ -123,31 +131,40 @@ func checkTopicNames(s *suite.Suite, rep *Report) {
|
||||
// checkSelfGoverning guards the document without a topic.
|
||||
//
|
||||
// A topic-less document is the one the suite governs itself by: it travels
|
||||
// nowhere and cannot be subscribed to, so it gets no spread checks. Nothing in
|
||||
// the manifest tells it from a convention that lost its topic key, and the
|
||||
// silent loss is the expensive one — the file keeps every check of form while
|
||||
// quietly dropping every check about travelling to a consumer. Two markers make
|
||||
// that loss visible: such a document is one per suite, and it has no layer of
|
||||
// its own, hence neither axis keys nor a base.
|
||||
// nowhere and cannot be subscribed to, so it gets no spread checks. A
|
||||
// convention that lost its topic key looks exactly the same, and that loss is
|
||||
// the expensive one — the file keeps every check of form while quietly dropping
|
||||
// every check about travelling to a consumer.
|
||||
//
|
||||
// Which file it is, the manifest says. Guessing was tried and does not reach:
|
||||
// "there is one of them" and "it has no axis keys" both hold for a suite whose
|
||||
// only topic-less file is a convention with the key knocked out.
|
||||
func checkSelfGoverning(s *suite.Suite, rep *Report) {
|
||||
var topicless []string
|
||||
declared := s.Manifest.Governance
|
||||
if declared != "" && !s.Exists(declared) {
|
||||
rep.Errorf(Manifest, manifest.Name, 0,
|
||||
"governance names %q, and the file is missing", declared)
|
||||
}
|
||||
|
||||
for _, d := range s.Docs {
|
||||
if d.Front.Topic != "" {
|
||||
if s.Manifest.Governs(d.Path) {
|
||||
rep.Errorf(Manifest, d.Path, d.Front.At["topic"],
|
||||
"the manifest names this file the one the suite governs itself by, and it declares topic %q: such a document belongs to no topic, because nobody may subscribe to it", d.Front.Topic)
|
||||
}
|
||||
continue
|
||||
}
|
||||
topicless = append(topicless, d.Path)
|
||||
|
||||
if d.Front.Axis() || d.Front.Extends != "" {
|
||||
switch {
|
||||
case declared == "":
|
||||
rep.Errorf(Manifest, d.Path, 1,
|
||||
"the file declares no topic, and the manifest names no document the suite governs itself by: either the topic key is lost, or the manifest has to say governance = %q", d.Path)
|
||||
case !s.Manifest.Governs(d.Path):
|
||||
rep.Errorf(Manifest, d.Path, 1,
|
||||
"the file declares no topic, while the manifest names %q as the one the suite governs itself by: a convention without a topic has lost the key", declared)
|
||||
case d.Front.Axis() || d.Front.Extends != "":
|
||||
rep.Errorf(Manifest, d.Path, d.Front.At["prefix"],
|
||||
"the file declares no topic yet carries the keys of a layer: a document without a topic is the one the suite governs itself by, and it is nobody's layer — the topic key looks lost")
|
||||
}
|
||||
}
|
||||
if len(topicless) > 1 {
|
||||
sort.Strings(topicless)
|
||||
for _, path := range topicless[1:] {
|
||||
rep.Errorf(Manifest, path, 1,
|
||||
"the suite holds more than one document without a topic (%v): only the one the suite governs itself by may lack a topic, so the rest have lost the key",
|
||||
topicless)
|
||||
"the document the suite governs itself by carries the keys of a layer: it is nobody's layer, having no topic to be a layer of")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +181,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 +241,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "; ") + "."
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/check"
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy check stays at the top level and reaches for no suite. The form of a
|
||||
// rule is one and the same, the local rules of the repository on X prefixes are
|
||||
// written by that same form, and checking what lies here has to work without a
|
||||
// network and without knowing where the copies came from.
|
||||
|
||||
func runCheck(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy check", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to check; every one of them by default")
|
||||
quiet := fs.Bool("quiet", false, "print findings only")
|
||||
asJSON := fs.Bool("json", false, "write the findings as JSON, for a caller that is not a person")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
if code := noStrayArgs(env, "convy check", fs.Args()); code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
dir, code := projectRoot(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
m, code := loadProject(env, dir)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
if err := distinctDirs(m); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Usage
|
||||
}
|
||||
names, code := components(env, m, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
var docs []*doc.Document
|
||||
var broken []error
|
||||
for _, name := range names {
|
||||
c := m.Components[name]
|
||||
if c.Dir == "" {
|
||||
fmt.Fprintf(env.Err, "the component %q names no dir, and there is nothing to look in\n", name)
|
||||
return Usage
|
||||
}
|
||||
found, errs := copies(dir, c.Dir)
|
||||
docs = append(docs, found...)
|
||||
broken = append(broken, errs...)
|
||||
}
|
||||
docs = distinct(docs)
|
||||
|
||||
rep := check.Copies(docs)
|
||||
for _, err := range broken {
|
||||
fmt.Fprintf(env.Err, "%s\n", err)
|
||||
}
|
||||
if *asJSON {
|
||||
if code := printJSON(env, rep); code != OK {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
printCopyReport(env.Out, rep, len(docs), len(names), *quiet)
|
||||
}
|
||||
if rep.Errors() > 0 || len(broken) > 0 {
|
||||
return Failed
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// copies collects the assembled conventions of one component directory.
|
||||
//
|
||||
// What is a copy is decided by the origin key rather than by the name of the
|
||||
// file: README.md belongs to the repository, READING.md belongs to the suite,
|
||||
// and a file whose origin key was taken away has become a document of the
|
||||
// repository — none of the three answers to the form of a rule.
|
||||
func copies(root, dir string) ([]*doc.Document, []error) {
|
||||
var docs []*doc.Document
|
||||
var broken []error
|
||||
base := filepath.Join(root, filepath.FromSlash(dir))
|
||||
|
||||
err := filepath.WalkDir(base, func(name string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
d, err := doc.Load(rel, name)
|
||||
if err != nil {
|
||||
broken = append(broken, err)
|
||||
return nil
|
||||
}
|
||||
if d.Front.Origin == "" {
|
||||
return nil
|
||||
}
|
||||
docs = append(docs, d)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
broken = append(broken, fmt.Errorf("walking %s: %w", dir, err))
|
||||
}
|
||||
sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path })
|
||||
return docs, broken
|
||||
}
|
||||
|
||||
// distinct drops a document reached through two components. Directories are
|
||||
// checked for equality before this, but one may still lie inside another, and a
|
||||
// finding printed twice reads as two.
|
||||
func distinct(docs []*doc.Document) []*doc.Document {
|
||||
seen := make(map[string]bool, len(docs))
|
||||
out := docs[:0]
|
||||
for _, d := range docs {
|
||||
if seen[d.Path] {
|
||||
continue
|
||||
}
|
||||
seen[d.Path] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func printCopyReport(w io.Writer, rep *check.Report, files, comps int, quiet bool) {
|
||||
findings := rep.Findings()
|
||||
printFindings(w, findings)
|
||||
|
||||
if quiet {
|
||||
return
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "project: %s in %s\n", plural(files, "file"), plural(comps, "component"))
|
||||
switch {
|
||||
case rep.Errors() > 0:
|
||||
fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings())
|
||||
case rep.Warnings() > 0:
|
||||
fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
|
||||
case files == 0:
|
||||
fmt.Fprintf(w, "nothing to check: no file carries an origin key; convy pull assembles the copies\n")
|
||||
default:
|
||||
fmt.Fprintf(w, "the copies hold the form; everything below %s is the repository's own\n", project.LocalMarker)
|
||||
}
|
||||
}
|
||||
+44
-7
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
}
|
||||
@@ -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, " ") + ")"
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/source"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
// A project command works against two levels at once: the manifest lying in the
|
||||
// repository and the suite it names. Reaching the suite costs a clone when the
|
||||
// reference is a git one, so it is done once per command and released at the
|
||||
// end — nothing of the suite is left lying about in the project.
|
||||
|
||||
// opened is a project together with the levels above it.
|
||||
type opened struct {
|
||||
Root string
|
||||
Manifest *manifest.Project
|
||||
Suite *suite.Suite
|
||||
// LangRoot is where the documents about the language lie. It is the root
|
||||
// of the suite while the language has no repository of its own.
|
||||
LangRoot string
|
||||
|
||||
trees []*source.Tree
|
||||
}
|
||||
|
||||
// Close releases whatever the opening fetched.
|
||||
func (o *opened) Close() {
|
||||
for _, t := range o.trees {
|
||||
t.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// openProject finds the project, resolves its source and loads the suite.
|
||||
func openProject(env Env, root string) (*opened, ExitCode) {
|
||||
dir, code := projectRoot(env, root)
|
||||
if code != OK {
|
||||
return nil, code
|
||||
}
|
||||
m, code := loadProject(env, dir)
|
||||
if code != OK {
|
||||
return nil, code
|
||||
}
|
||||
if m.Source == "" {
|
||||
fmt.Fprintf(env.Err, "%s names no source: a copy comes from a suite, and the manifest is where the suite is named\n", m.Path)
|
||||
return nil, Usage
|
||||
}
|
||||
|
||||
ref, err := source.Parse(m.Source)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return nil, Usage
|
||||
}
|
||||
tree, err := source.Open(ref, dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return nil, Failed
|
||||
}
|
||||
|
||||
o := &opened{Root: dir, Manifest: m, LangRoot: tree.Dir(), trees: []*source.Tree{tree}}
|
||||
o.Suite, err = suite.Load(tree.Dir())
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err))
|
||||
o.Close()
|
||||
return nil, Failed
|
||||
}
|
||||
|
||||
// The language is a level of its own, and a suite may keep its documents
|
||||
// apart from itself. While it does not, the suite is where they lie.
|
||||
if spec := o.Suite.Manifest.Language.Source; spec != "" {
|
||||
ref, err := source.Parse(spec)
|
||||
if err != nil {
|
||||
o.Close()
|
||||
fmt.Fprintf(env.Err, "%s: [language] source: %s\n", o.Suite.Manifest.Path, err)
|
||||
return nil, Usage
|
||||
}
|
||||
langTree, err := source.Open(ref, tree.Dir())
|
||||
if err != nil {
|
||||
o.Close()
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return nil, Failed
|
||||
}
|
||||
o.trees = append(o.trees, langTree)
|
||||
o.LangRoot = langTree.Dir()
|
||||
}
|
||||
return o, OK
|
||||
}
|
||||
|
||||
// loadProject reads the project manifest and says what it did not understand.
|
||||
// A typo in a key costs a whole component: `dyr` instead of `dir` leaves the
|
||||
// component pointing at the root of the repository, and nothing else would say
|
||||
// so.
|
||||
func loadProject(env Env, dir string) (*manifest.Project, ExitCode) {
|
||||
m, err := manifest.LoadProject(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return nil, Usage
|
||||
}
|
||||
for _, key := range m.Undecoded {
|
||||
fmt.Fprintf(env.Err, "warning: %s: the key %s is unknown to the tool\n", m.Path, key)
|
||||
}
|
||||
return m, OK
|
||||
}
|
||||
|
||||
// projectRoot finds the manifest of the project. A project command typed inside
|
||||
// a suite does not do anything at a guess: it says where it is and names the
|
||||
// command of that level.
|
||||
func projectRoot(env Env, given string) (string, ExitCode) {
|
||||
if given != "" {
|
||||
return given, OK
|
||||
}
|
||||
found, err := manifest.FindProject(env.Dir)
|
||||
if err == nil {
|
||||
return found, OK
|
||||
}
|
||||
if _, suiteErr := manifest.Find(env.Dir); suiteErr == nil {
|
||||
fmt.Fprintf(env.Err, "this is a conventions suite, not a project that takes copies: %s lies here, %s does not\n", manifest.Name, manifest.ProjectName)
|
||||
fmt.Fprintln(env.Err, "the commands of a suite are under convy suite")
|
||||
return "", Usage
|
||||
}
|
||||
fmt.Fprintf(env.Err, "not a project with conventions: no %s here or above\n", manifest.ProjectName)
|
||||
fmt.Fprintln(env.Err, "convy init wires one up")
|
||||
return "", Usage
|
||||
}
|
||||
|
||||
// componentOf resolves the --for flag against the manifest.
|
||||
func componentOf(env Env, m *manifest.Project, name string) (string, manifest.Component, ExitCode) {
|
||||
got, c, err := m.Only(name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return "", manifest.Component{}, Usage
|
||||
}
|
||||
if c.Dir == "" {
|
||||
fmt.Fprintf(env.Err, "the component %q names no dir, and a copy has to be written somewhere\n", got)
|
||||
return "", manifest.Component{}, Usage
|
||||
}
|
||||
return got, c, OK
|
||||
}
|
||||
|
||||
// components picks the components a command works on: the one named by --for,
|
||||
// or every one of them.
|
||||
func components(env Env, m *manifest.Project, name string) ([]string, ExitCode) {
|
||||
if name != "" {
|
||||
if _, ok := m.Components[name]; !ok {
|
||||
fmt.Fprintf(env.Err, "the project declares no component %q; it declares: %s\n", name, strings.Join(m.Names(), ", "))
|
||||
return nil, Usage
|
||||
}
|
||||
return []string{name}, OK
|
||||
}
|
||||
if len(m.Components) == 0 {
|
||||
fmt.Fprintf(env.Err, "%s declares no component, and a copy is assembled for a component\n", m.Path)
|
||||
return nil, Usage
|
||||
}
|
||||
return m.Names(), OK
|
||||
}
|
||||
|
||||
// distinctDirs checks that no two components write into the same place. Two
|
||||
// copies of one topic would otherwise collide by name, and that is an error of
|
||||
// the manifest rather than a reason to rename files.
|
||||
//
|
||||
// One directory inside another is the same error told less plainly: whatever
|
||||
// walks the outer one finds the copies of the inner, and every command that
|
||||
// counts files counts them twice.
|
||||
func distinctDirs(m *manifest.Project) error {
|
||||
dirs := make(map[string]string, len(m.Components))
|
||||
for _, name := range m.Names() {
|
||||
dirs[name] = filepath.ToSlash(filepath.Clean(m.Components[name].Dir))
|
||||
}
|
||||
names := m.Names()
|
||||
for i, a := range names {
|
||||
for _, b := range names[i+1:] {
|
||||
switch {
|
||||
case dirs[a] == dirs[b]:
|
||||
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", a, b, dirs[a])
|
||||
case within(dirs[a], dirs[b]):
|
||||
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", b, dirs[b], a, dirs[a])
|
||||
case within(dirs[b], dirs[a]):
|
||||
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", a, dirs[a], b, dirs[b])
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// within reports whether inner lies under outer.
|
||||
func within(outer, inner string) bool {
|
||||
return strings.HasPrefix(inner, outer+"/")
|
||||
}
|
||||
|
||||
// exists reports whether a path is there.
|
||||
func exists(name string) bool {
|
||||
_, err := os.Stat(name)
|
||||
return err == nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
+48
-15
@@ -18,6 +18,7 @@ func runSuiteCheck(env Env, args []string) ExitCode {
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory")
|
||||
quiet := fs.Bool("quiet", false, "print findings only")
|
||||
asJSON := fs.Bool("json", false, "write the findings as JSON, for a caller that is not a person")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
@@ -46,30 +47,35 @@ func runSuiteCheck(env Env, args []string) ExitCode {
|
||||
}
|
||||
|
||||
rep := check.Suite(s)
|
||||
if *asJSON {
|
||||
if code := printJSON(env, rep); code != OK {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
printReport(env.Out, rep, s, *quiet)
|
||||
}
|
||||
if rep.Errors() > 0 {
|
||||
return Failed
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// printJSON writes the findings for a machine. It is the same report the person
|
||||
// gets, in the same order — a second answer that disagreed with the first would
|
||||
// be worse than no second answer.
|
||||
func printJSON(env Env, rep *check.Report) ExitCode {
|
||||
body, err := rep.JSON()
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return Failed
|
||||
}
|
||||
env.Out.Write(body)
|
||||
return OK
|
||||
}
|
||||
|
||||
func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
|
||||
findings := rep.Findings()
|
||||
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 +87,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 +110,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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/check"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
// convy sync makes the files agree with the manifest. The manifest is the
|
||||
// truth: it says which components exist, where they write and what each takes,
|
||||
// and everything under those directories follows from that.
|
||||
//
|
||||
// It divides from pull by what it is about. pull is about the contents of a
|
||||
// copy — it takes the text of every subscription afresh, and the diff it leaves
|
||||
// is the point of running it. sync is about the set of files: what the manifest
|
||||
// calls for and is not there gets assembled, what is there and nothing calls
|
||||
// for gets reported and, when nothing of the repository is in it, removed.
|
||||
//
|
||||
// A copy carrying a local part is never removed. Below the marker is the one
|
||||
// thing in the directory that exists nowhere else, and a command that tidies up
|
||||
// has no business deciding it is spent.
|
||||
|
||||
func runSync(env Env, args []string) ExitCode {
|
||||
fs := flag.NewFlagSet("convy sync", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
||||
forComponent := fs.String("for", "", "component to bring in line; every one of them by default")
|
||||
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Usage
|
||||
}
|
||||
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
o, code := openProject(env, *root)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
defer o.Close()
|
||||
|
||||
names, code := components(env, o.Manifest, *forComponent)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
|
||||
problems := validateManifest(o, names)
|
||||
for _, p := range problems {
|
||||
fmt.Fprintf(env.Err, "%s\n", p)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
|
||||
return Failed
|
||||
}
|
||||
|
||||
changed, left := 0, 0
|
||||
for i, name := range names {
|
||||
c := o.Manifest.Components[name]
|
||||
if i > 0 {
|
||||
fmt.Fprintln(env.Out)
|
||||
}
|
||||
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
|
||||
n, stuck, code := syncComponent(env, o, c, *dry)
|
||||
if code != OK {
|
||||
return code
|
||||
}
|
||||
changed += n
|
||||
left += stuck
|
||||
}
|
||||
|
||||
fmt.Fprintln(env.Out)
|
||||
switch {
|
||||
case *dry && changed > 0:
|
||||
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
|
||||
case changed > 0:
|
||||
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
|
||||
case left == 0:
|
||||
fmt.Fprintln(env.Out, "the layout already follows the manifest")
|
||||
}
|
||||
if left > 0 {
|
||||
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
|
||||
return Failed
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// validate checks the manifest against itself and against the suite. Everything
|
||||
// wrong is reported at once: being sent back one line at a time is the worst way
|
||||
// to learn what a file wants.
|
||||
func validateManifest(o *opened, names []string) []string {
|
||||
var out []string
|
||||
if err := distinctDirs(o.Manifest); err != nil {
|
||||
out = append(out, err.Error())
|
||||
}
|
||||
for _, name := range names {
|
||||
c := o.Manifest.Components[name]
|
||||
if c.Dir == "" {
|
||||
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
|
||||
}
|
||||
if len(c.Lang) > 1 {
|
||||
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
|
||||
name, strings.Join(c.Lang, ", ")))
|
||||
}
|
||||
seen := make(map[string]bool, len(c.Topics))
|
||||
for _, topic := range c.Topics {
|
||||
switch {
|
||||
case seen[topic]:
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
|
||||
case o.Suite.Manifest.TopicRetired(topic):
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
|
||||
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
|
||||
case !o.Suite.Manifest.TopicLive(topic):
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
|
||||
default:
|
||||
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
|
||||
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
|
||||
}
|
||||
}
|
||||
seen[topic] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// syncComponent brings one directory in line. It returns how many files moved
|
||||
// and how many it would not touch.
|
||||
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
|
||||
|
||||
for _, topic := range c.Topics {
|
||||
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
|
||||
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
|
||||
continue
|
||||
}
|
||||
changed++
|
||||
if dry {
|
||||
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
|
||||
continue
|
||||
}
|
||||
made, err := project.Assemble(o.Suite, o.Root, c, topic)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
|
||||
}
|
||||
|
||||
orphans, err := orphaned(o.Root, c)
|
||||
if err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
for _, orphan := range orphans {
|
||||
switch {
|
||||
case orphan.local:
|
||||
left++
|
||||
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
|
||||
orphan.path, orphan.topic)
|
||||
case dry:
|
||||
changed++
|
||||
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
|
||||
default:
|
||||
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
|
||||
fmt.Fprintln(env.Err, err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
changed++
|
||||
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
|
||||
}
|
||||
}
|
||||
|
||||
if !dry {
|
||||
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, " %s\n", err)
|
||||
return changed, left, Failed
|
||||
}
|
||||
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
|
||||
}
|
||||
reportDangling(env, o, c)
|
||||
return changed, left, OK
|
||||
}
|
||||
|
||||
// reportDangling says which copies point at topics this component did not take.
|
||||
// It is a note rather than a finding: naming a rule of another topic outside
|
||||
// the norm is allowed, and the reader loses a pointer rather than a
|
||||
// requirement. But it is also the one thing about a copy that cannot be seen
|
||||
// without the suite, so it is said where the suite is at hand.
|
||||
func reportDangling(env Env, o *opened, c manifest.Component) {
|
||||
docs, _ := copies(o.Root, c.Dir)
|
||||
for _, d := range docs {
|
||||
refs := check.Dangling(d, o.Suite, c.Subscribed)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
var parts []string
|
||||
for _, ref := range refs {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", ref.Text, check.TopicOf(o.Suite, ref.Prefix)))
|
||||
}
|
||||
fmt.Fprintf(env.Out, " ? %-28s points at %s — not taken by this component\n",
|
||||
d.Path, strings.Join(parts, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// orphan is a copy in a component directory that the manifest does not call for.
|
||||
type orphan struct {
|
||||
path string
|
||||
topic string
|
||||
local bool
|
||||
}
|
||||
|
||||
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
|
||||
// the origin key: README.md belongs to the repository, READING.md belongs to the
|
||||
// suite, and a file whose origin was taken away has become a document of the
|
||||
// repository — none of the three is anyone's to remove.
|
||||
func orphaned(root string, c manifest.Component) ([]orphan, error) {
|
||||
docs, broken := copies(root, c.Dir)
|
||||
if len(broken) > 0 {
|
||||
return nil, broken[0]
|
||||
}
|
||||
var out []orphan
|
||||
for _, d := range docs {
|
||||
if c.Subscribed(d.Front.Origin) {
|
||||
continue
|
||||
}
|
||||
local := ""
|
||||
if at := d.Marker(); at > 0 {
|
||||
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
|
||||
}
|
||||
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/cli"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
// subscribe edits the project manifest the way a person would: by hand, in the
|
||||
// file. That is the whole premise of sync — the manifest is the truth, and the
|
||||
// layout follows it.
|
||||
func subscribe(t *testing.T, root string, topics ...string) {
|
||||
t.Helper()
|
||||
m, err := manifest.LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := m.Components["backend"]
|
||||
c.Topics = topics
|
||||
m.Components["backend"] = c
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAssemblesWhatIsMissingAndRemovesWhatIsOrphaned(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
// The manifest is edited by hand: time goes, logging comes.
|
||||
subscribe(t, root, "logging")
|
||||
|
||||
code, out := run(t, root, "", false, "sync", "--dry-run")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("the dry run returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "would change") {
|
||||
t.Errorf("the dry run promised nothing:\n%s", out)
|
||||
}
|
||||
if !exists(t, root, "docs/conventions/time.md") {
|
||||
t.Errorf("the dry run removed a file")
|
||||
}
|
||||
if exists(t, root, "docs/conventions/logging.md") {
|
||||
t.Errorf("the dry run assembled a file")
|
||||
}
|
||||
|
||||
code, out = run(t, root, "", false, "sync")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("sync returned %d: %s", code, out)
|
||||
}
|
||||
if !exists(t, root, "docs/conventions/logging.md") {
|
||||
t.Errorf("the subscribed topic was not assembled:\n%s", out)
|
||||
}
|
||||
if exists(t, root, "docs/conventions/time.md") {
|
||||
t.Errorf("the copy nothing subscribes to stayed:\n%s", out)
|
||||
}
|
||||
|
||||
// Run again: nothing left to do, and it says so.
|
||||
code, out = run(t, root, "", false, "sync")
|
||||
if code != cli.OK || !strings.Contains(out, "already follows the manifest") {
|
||||
t.Errorf("a second sync found work to do:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Below the marker is the one thing in the directory that exists nowhere else.
|
||||
func TestSyncLeavesAnOrphanCarryingALocalPart(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscribe(t, root)
|
||||
|
||||
code, out := run(t, root, "", false, "sync")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("an orphan with a local part went unremarked:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "local part") {
|
||||
t.Errorf("the report does not say why the file was left:\n%s", out)
|
||||
}
|
||||
if !exists(t, root, "docs/conventions/time.md") {
|
||||
t.Fatalf("the local part was destroyed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest is the truth, so a manifest that does not hold together stops
|
||||
// the command before anything is written.
|
||||
func TestSyncValidatesTheManifestBeforeTouchingAnything(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*manifest.Project)
|
||||
want string
|
||||
}{{
|
||||
name: "a topic the suite does not declare",
|
||||
change: func(m *manifest.Project) { subscribeTo(m, "billing") },
|
||||
want: "no such topic",
|
||||
}, {
|
||||
name: "the same topic twice",
|
||||
change: func(m *manifest.Project) { subscribeTo(m, "time", "time") },
|
||||
want: "twice",
|
||||
}, {
|
||||
name: "two languages in one component",
|
||||
change: func(m *manifest.Project) {
|
||||
c := m.Components["backend"]
|
||||
c.Lang = []string{"go", "javascript"}
|
||||
m.Components["backend"] = c
|
||||
},
|
||||
want: "declares two languages",
|
||||
}, {
|
||||
name: "a topic no layer of which fits",
|
||||
change: func(m *manifest.Project) {
|
||||
// web-ui lives on the htmx stack only, and this component is on
|
||||
// no stack at all.
|
||||
subscribeTo(m, "web-ui")
|
||||
},
|
||||
want: "no layer of it fits",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, err := manifest.LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.change(m)
|
||||
if err := m.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out := run(t, root, "", false, "sync")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("the manifest went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("the report does not say %q:\n%s", tc.want, out)
|
||||
}
|
||||
if !strings.Contains(out, "nothing was touched") {
|
||||
t.Errorf("the report does not say it wrote nothing:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeTo(m *manifest.Project, topics ...string) {
|
||||
c := m.Components["backend"]
|
||||
c.Topics = topics
|
||||
m.Components["backend"] = c
|
||||
}
|
||||
|
||||
func exists(t *testing.T, parts ...string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(parts...))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// One directory inside another is the same collision told less plainly, and it
|
||||
// makes every command that walks a component find the copies of the other.
|
||||
func TestNestedComponentDirectoriesAreRefused(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs", "--lang", "go")
|
||||
addComponent(t, root, "web", manifest.Component{Dir: "docs/web", Topics: []string{}})
|
||||
|
||||
for _, name := range []string{"sync", "pull", "check"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
code, out := run(t, root, "", false, name)
|
||||
if code == cli.OK {
|
||||
t.Fatalf("nested directories went through:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lies inside") {
|
||||
t.Errorf("the refusal does not say what is nested:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A copy may name a rule of a topic the component did not take: naming one
|
||||
// outside the norm is allowed, and the reader loses a pointer rather than a
|
||||
// requirement. It is still the one thing about a copy that cannot be seen
|
||||
// without the suite, so sync says it.
|
||||
func TestSyncNotesAReferenceToAnUnsubscribedTopic(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
body += "\nПодробности — SLOG-1.\n"
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out := run(t, root, "", false, "sync")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("sync returned %d: %s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "SLOG-1") || !strings.Contains(out, "logging") {
|
||||
t.Errorf("the note does not name the reference and its topic:\n%s", out)
|
||||
}
|
||||
|
||||
// Subscribed, it is no longer dangling.
|
||||
run(t, root, "", false, "add", "logging")
|
||||
_, out = run(t, root, "", false, "sync")
|
||||
if strings.Contains(out, "not taken by this component") {
|
||||
t.Errorf("the note stayed after the topic was taken:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The findings a person reads and the findings a machine reads are the same
|
||||
// findings, in the same order.
|
||||
func TestCheckWritesJSONForACallerThatIsNotAPerson(t *testing.T) {
|
||||
suiteRoot := subscribable(t)
|
||||
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
||||
run(t, root, "", false, "add", "time")
|
||||
|
||||
code, out := run(t, root, "", false, "check", "--json")
|
||||
if code != cli.OK {
|
||||
t.Fatalf("convy check --json returned %d: %s", code, out)
|
||||
}
|
||||
var clean struct {
|
||||
Findings []map[string]any `json:"findings"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &clean); err != nil {
|
||||
t.Fatalf("the output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if len(clean.Findings) != 0 || clean.Errors != 0 {
|
||||
t.Errorf("a sound project reported findings: %s", out)
|
||||
}
|
||||
|
||||
// Now break it and read the finding back.
|
||||
name := filepath.Join(root, "docs", "conventions", "time.md")
|
||||
body := read(t, root, "docs/conventions/time.md")
|
||||
body = strings.Replace(body, "<!-- conv:local -->", "", 1)
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, out = run(t, root, "", false, "check", "--json")
|
||||
if code == cli.OK {
|
||||
t.Fatalf("the broken copy passed:\n%s", out)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &clean); err != nil {
|
||||
t.Fatalf("the output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if clean.Errors != 1 || len(clean.Findings) != 1 {
|
||||
t.Fatalf("expected one error, got %s", out)
|
||||
}
|
||||
f := clean.Findings[0]
|
||||
for key, want := range map[string]any{"severity": "error", "family": "spread"} {
|
||||
if f[key] != want {
|
||||
t.Errorf("%s is %v, expected %v", key, f[key], want)
|
||||
}
|
||||
}
|
||||
if f["path"] != "docs/conventions/time.md" {
|
||||
t.Errorf("path is %v", f["path"])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -292,6 +292,78 @@ 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. Both halves have to agree: the words say which natural language,
|
||||
// the number says which version of the language, and two versions of one
|
||||
// natural language would otherwise be told apart by nothing at all.
|
||||
func Recognize(text string) (Vocabulary, bool) {
|
||||
for _, version := range sortedVersions() {
|
||||
for _, code := range sortedCodes(version) {
|
||||
v := registry[version][code]
|
||||
if namesAll(text, v.Words()) && matchesVersion(text, v.Version) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Vocabulary{}, false
|
||||
}
|
||||
|
||||
func namesAll(text string, words []string) bool {
|
||||
for _, w := range words {
|
||||
if !strings.Contains(text, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchesVersion looks for the number as a whole word, so that version 1 is not
|
||||
// read out of "version 12".
|
||||
func matchesVersion(text string, version int) bool {
|
||||
number := fmt.Sprint(version)
|
||||
for i := 0; ; {
|
||||
j := strings.Index(text[i:], number)
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
start, end := i+j, i+j+len(number)
|
||||
before := start == 0 || !isDigit(rune(text[start-1]))
|
||||
after := end == len(text) || !isDigit(rune(text[end]))
|
||||
if before && after {
|
||||
return true
|
||||
}
|
||||
i = end
|
||||
if i >= len(text) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
|
||||
|
||||
func sortedVersions() []int {
|
||||
out := make([]int, 0, len(registry))
|
||||
for v := range registry {
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCodes(version int) []string {
|
||||
out := make([]string, 0, len(registry[version]))
|
||||
for c := range registry[version] {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Foreign lists the words of the other vocabularies of the same version — the
|
||||
// ones that give away a mixture of vocabularies. Words that coincide with the
|
||||
// suite's own are dropped.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package lang_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
@@ -72,3 +73,53 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Words say which natural language, the number says which version. One without
|
||||
// the other names no vocabulary: two versions of the same language would be
|
||||
// told apart by nothing at all.
|
||||
func TestRecognizeNeedsTheVersionToAgree(t *testing.T) {
|
||||
ru, err := lang.Lookup(1, "ru")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := ru.VersionLine()
|
||||
|
||||
if _, ok := lang.Recognize(line); !ok {
|
||||
t.Fatalf("the line the tool itself writes was not recognized")
|
||||
}
|
||||
wrong := strings.Replace(line, "версии 1", "версии 7", 1)
|
||||
if wrong == line {
|
||||
t.Fatal("the fixture did not change the version")
|
||||
}
|
||||
if v, ok := lang.Recognize(wrong); ok {
|
||||
t.Errorf("a line naming version 7 was read as version %d (%s)", v.Version, v.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, `"`, `\"`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
-17
@@ -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,75 @@ 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"`
|
||||
// Governance is the path of the document the suite governs itself by: the
|
||||
// one written in the conventions language yet belonging to no topic, so
|
||||
// that nobody can subscribe to it and it travels nowhere.
|
||||
//
|
||||
// It is declared rather than guessed. A convention that lost its topic key
|
||||
// looks exactly like it, and the loss is the expensive one — the file keeps
|
||||
// every check of form while quietly dropping every check about travelling
|
||||
// to a consumer.
|
||||
Governance string `toml:"governance,omitempty"`
|
||||
Topics Section `toml:"topics,omitempty"`
|
||||
Prefixes Section `toml:"prefixes,omitempty"`
|
||||
|
||||
// Path is where the manifest was read from.
|
||||
Path string `toml:"-"`
|
||||
@@ -84,15 +133,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)
|
||||
@@ -135,6 +216,11 @@ func (m *Manifest) PathOf(prefix string) (string, bool) {
|
||||
return path, ok
|
||||
}
|
||||
|
||||
// Governs reports whether the path is the document the suite governs itself by.
|
||||
func (m *Manifest) Governs(path string) bool {
|
||||
return m.Governance != "" && filepath.ToSlash(m.Governance) == filepath.ToSlash(path)
|
||||
}
|
||||
|
||||
// TopicLive reports whether the topic is declared among the live ones.
|
||||
func (m *Manifest) TopicLive(topic string) bool {
|
||||
_, ok := m.Topics.Live[topic]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user