Compare commits

..
9 Commits
Author SHA1 Message Date
av 4615de6e86 заведены README.md и CLAUDE.md
- README описывает термины, команды, два режима и вывод проверки; модель не
  пересказывается — истина остаётся в репозитории набора
- CLAUDE.md фиксирует распределение языков, инварианты кода и решения, которые
  уже приняты и не пересматриваются без просьбы
- отдельным разделом перечислены известные остатки, чтобы их не искали заново
2026-07-27 11:58:49 +03:00
av 8331aa1ca5 suite rule и suite list
- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки
  раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом
  с тем, которое оно уточняет
- ступень называется категорией (requirement, prohibition, ...), а не словом
  языка, поэтому вызывающему не нужно знать, на каком языке записан набор
- suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор
  слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды
- слои темы теперь всегда возвращаются базовым вперёд
2026-07-27 11:36:19 +03:00
av 5321fba89d suite retire и проверка словаря в документе для читателя
- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой
  с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными
  ссылками отклоняется с перечнем мест
- добавлена проверка META-30: короткое описание языка обязано называть все
  слова словаря, иначе читатель копии толкует их по памяти
- retired-раздел манифеста больше не считается объявлением пути: снятый
  префикс означает, что файл ушёл вместе с ним
2026-07-27 10:51:46 +03:00
av b29b5b5e6f suite init и suite add: заведение набора и конвенции в двух режимах
- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет
  файл конвенции и вправляет запись в suite.toml, сохраняя комментарии
- без аргументов команды спрашивают поля с подсказками, с флагами берут всё
  сразу и не спрашивают ничего; без терминала пустой вызов отказывает
- строка о версии языка генерируется из словаря набора, поэтому созданный файл
  проходит suite check без правок
- починено разрешение extends: короткая форма бралась по суффиксу и могла
  указать на сам файл; теперь неоднозначность либо избегается при записи, либо
  сообщается ошибкой
2026-07-27 10:40:57 +03:00
av 709157237d suite check: направление ссылок внутри темы проверяется во всём документе
- ссылка на слой своей темы с объявленной осью — ошибка где угодно, а не только
  в норме: гарантированно присутствует в копии один базовый слой
- ссылки на чужую тему вне нормы остаются разрешёнными по META-20
2026-07-27 10:27:30 +03:00
av 51d2050200 suite check: закрыты пробелы в проверках шапки, иерархии и словаря
- документ без темы: ошибка, если такой не один или если он несёт ключи слоя —
  иначе конвенция, потерявшая topic, молча лишалась всех проверок распространения
- иерархия заголовков: один заголовок первого уровня, без перескоков через уровень
- имя темы: латиница и пригодность для имени файла — ошибкой, kebab-case —
  предупреждением
- сценарные связки чужого словаря ловятся в начале строки, где их ставит
  сценарный блок, а не в середине фразы, где это чаще SQL
2026-07-27 10:16:15 +03:00
av b2d07ae55d комментарии и сообщения переведены на английский
- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском
- по-русски остались только литералы словаря ru и содержимое фикстур: это
  данные под проверкой, а не текст инструмента
- согласование числительных в итоге упростилось до английского plural
2026-07-27 10:04:27 +03:00
av 0b8cc125b3 suite check: заголовок правила больше не разбирается как ссылка
- заголовок правило объявляет, а не ссылается на него: при опечатке в префиксе
  одна ошибка формы давала вторую находку о неразрешённой ссылке
- числительные в итоге согласованы с существительным
2026-07-27 09:55:11 +03:00
av ccf046fb7b suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками
- проверки формы правила, распространения, ссылок и самого suite.toml
- словарь языка живёт в бинаре реестром «версия × естественный язык», темы и
  префиксы берутся только из манифеста
2026-07-27 09:53:07 +03:00
33 changed files with 6140 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.
## Язык
Общение по репозиторию — на русском. Внутри репозитория язык распределён так, и
это распределение проверяется глазами при ревью:
- **английский** — комментарии в коде, имена, тексты ошибок, вывод CLI,
сообщения и имена тестов;
- **русский** — `README.md`, `CLAUDE.md`, сообщения коммитов;
- **язык проверяемого набора** — содержимое тестовых фикстур и литералы
словарей в `internal/lang`. Это данные под проверкой, а не текст инструмента,
и переводить их нельзя: сломается то, что они проверяют.
Слова словаря законно попадают внутрь английских сообщений, потому что
подставляются из набора: `rule GTIM-1 has no ПОЧЕМУ block`. Инструмент говорит
по-английски и цитирует то, чем записан проверяемый набор.
## Что это
CLI для управления конвенциями. Модель здесь не описывается: она живёт в
репозитории набора `dev-conventions``README.md` (набор и копии),
`LANGUAGE.md` (форма правила и список проверок), `GUIDE.md` (ведение набора,
префикс META), `TOOL.md` (решения об инструменте). При расхождении истина там,
а не в коде и не здесь.
Пользовательская сторона — в `README.md`. Ниже — то, что нужно знать, правя код.
## Раскладка
```
internal/lang словарь: реестр «версия языка × естественный язык»
internal/manifest suite.toml — чтение и текстовая правка
internal/doc разбор документа: шапка, области правил, блоки
internal/suite сборка набора в память, отбор слоёв под компонент
internal/check проверки: manifest, form, spread, links
internal/cli команды, диалог, два режима
```
Зависимость одна — `BurntSushi/toml`. Вторую не заводить: так решено в
`TOOL.md`, и `Undecoded()` этого парсера бесплатно ловит опечатки в ключах
манифеста.
## Инварианты кода
- **В коде нет ни одной темы, ни одного префикса, ни одного пути набора.** Всё
это приходит из манифеста. Список проверяемых файлов берётся из
`[prefixes.live]`, а не из дерева директорий: это и есть граница «язык
употребляет» против «язык цитирует».
- **Словарь зашит в бинарь** по паре «версия языка × естественный язык», а не
объявляется в манифесте. Когда спецификация языка уедет в отдельный
репозиторий и там появятся спеки словарей, источником станут они —
подменяется `registry`, тип `Vocabulary` и проверки поверх него не трогаются.
- **Что инструмент пишет, инструмент принимает.** Набор, созданный `suite init`,
`add` и `rule`, обязан проходить `suite check` без правок. Это проверяет
`checkClean` в `internal/cli`; ломать инвариант нельзя.
- **Манифест правится текстом, а не энкодером.** В `suite.toml` комментариев
больше, чем данных. `manifest.AddEntry` вставляет запись, подстраиваясь под
порядок таблицы: отсортированную по алфавиту держит отсортированной,
упорядоченную вручную дополняет в конец. Комментарий, отделённый пустой
строкой, принадлежит таблице **ниже** себя.
- **Разбор опирается на разметку, а не на суждение.** Область правила — от
заголовка до следующего заголовка любого уровня. Метка открывает блок только
первой в абзаце и полужирным. Огороженные блоки кода исключаются везде;
инлайн-код вырезается там, где ищутся ссылки, и не вырезается там, где ищутся
пути канона.
## Решения, которые уже приняты
Их не пересматривают без явной просьбы — каждое обсуждалось и стоило времени.
- **Порядок правил в файле — по читаемости, а не по номерам.** Номер стабилен и
не переиспользуется, поэтому «порядок по номерам» означал бы «порядок по
времени написания» навсегда. Проверяется сплошность нумерации, а не
возрастание.
- **Ссылка на чужую тему вне нормы разрешена** (META-20): обоснование,
потерявшее адресата, деградирует честно. Внутри своей темы ссылаться можно
только в базовый слой, и это проверяется во всём документе, а не только в
норме: гарантированно присутствует в копии один базовый слой.
- **Заголовок правила — объявление, а не ссылка.** При разборе ссылок строки
заголовков пропускаются.
- **Лок-файла, `push`, отчёта о расхождении и перенумерации не будет.** Модель
отвергает каждое явно.
## Проверки
Семейства повторяют деление из `LANGUAGE.md`, раздел «Что стоит проверять
машиной», и это деление держится в коде: `form` — в любом файле, который язык
употребляет; `spread` — только в конвенциях, потому что эти проверки о том, что
документ уезжает к потребителю. Третья часть списка (взаимоисключительность
строк таблицы, покрытие области действия, отвечает ли обоснование на «что
сломается») разбором текста не даётся и в коде отсутствует намеренно.
Новая проверка заводится вместе с двумя тестами: что она срабатывает и что она
**молчит** там, где не должна. Второй важнее: проверка, краснеющая на исправном
файле, выключается целиком. Ложные срабатывания собраны в
`TestNoFalsePositives`.
Перед тем как заводить проверку, стоит прогнать её замысел по живому канону
(`dev-conventions`): если она покраснеет на исправном наборе, замысел неверен.
## Известные остатки
- Набор без документа самоуправления, в котором конвенция потеряла `topic`,
проскочит: признаков «этот документ один» и «у него нет ключей слоя» не
хватает. Закрывается маркером в манифесте — правка формата, не сделана.
- Проверка пути канона считает путём любой токен `*.md`, который резолвится в
файл набора; упоминание `README.md` в конвенции она пометит ошибочно. На
текущем каноне не срабатывает.
- Машиночитаемого вывода находок (`--json`) нет.
## Тесты
```
go test ./... всё
go test ./internal/check/ -v проверки, по одному подтесту на случай
gofmt -l . && go vet ./... перед коммитом
```
Тесты фикстурные: набор пишется во временную директорию и прогоняется целиком.
`internal/cli` проверяет обе моды, включая диалог — интерактивный режим иначе не
покрыть, из шелла он требует терминала.
## Коммиты
Русский, строчная буква, без точки в конце, прошедшее время или страдательный
залог. Изредка область через двоеточие (`suite check:`). Тело — маркированный
список на 2–4 пункта с переносом по ~76 колонок, объясняет почему. Conventional
Commits и `Co-Authored-By` не используются.
## Состояние
Наборная сторона закончена: `init`, `add`, `rule`, `retire`, `list`, `check`.
Проектные команды (`add`, `pull`, `list`, `check` без `suite`) не начаты; отбор
слоёв под компонент для них уже написан — `suite.Assemble`, — и переписывать его
в сборщике не нужно.
Линтеров и CI нет.
+147
View File
@@ -0,0 +1,147 @@
# convy
CLI для управления конвенциями разработки: ведёт набор конвенций и собирает
копии в проектах.
Модель, которую инструмент реализует, описана не здесь, а в репозитории набора
(`dev-conventions`): `README.md` — устройство набора и копий, `LANGUAGE.md`
форма правила, `GUIDE.md` — правила ведения набора, `TOOL.md` — решения об этом
инструменте. При расхождении истина там.
## Термины
| Уровень | Что это |
|---|---|
| набор | репозиторий с конвенциями, манифестом `suite.toml` и обвязкой |
| тема | набор правил об одном фокусе разработки; единица подписки |
| слой | один файл темы: базовый, языковой или стековый |
| компонент | адресат сборки в проекте: один язык, один стек, один вид приложения |
| префикс | четыре заглавные латинские буквы, адрес правила: `GTIM-3` |
Тему и ось слоя объявляет шапка файла, а не путь: `topic:`, `lang:`, `stack:`.
Слой без ключей оси — базовый, он попадает в копию всегда.
## Установка
Внешняя зависимость одна (`BurntSushi/toml`), сборка обычная:
```
go install git.vakhrushev.me/av/convy@latest
```
или из клона репозитория:
```
go build -o convy .
```
Способ раздачи готовых бинарей пока не выбран — вопрос открыт в `TOOL.md`.
## Команды
```
В наборе:
convy suite init завести набор: директория и манифест
convy suite add завести конвенцию: файл, тема и префикс
convy suite rule дописать правило: следующий номер, блоки по порядку
convy suite retire снять правило, конвенцию или тему — без переиспользования
convy suite list что в наборе и что возьмёт компонент
convy suite check целостность набора: префиксы, темы, оси, ссылки, форма
В проекте (пока не реализовано):
convy add <тема> подписаться и собрать
convy pull пересобрать подписанное
convy list что подключено и что доступно
convy check проверить форму того, что здесь
```
Контекст определяется по манифесту рядом: `suite.toml` — набор,
`.conventions.toml` — проект. Наугад не делается ничего: команда не того уровня
отказывает и подсказывает нужную.
## Два режима
Команды, которые что-то меняют, работают в двух режимах, и режим читается по
командной строке.
**Без аргументов — диалог.** Каждое поле спрашивается с подсказкой о том, что
туда кладут и почему. Это для человека.
```
$ convy suite add
The focus the rules are about: time, config, db-schema. A topic is the unit of
subscription — a consumer takes it whole. The name never changes and is never reused.
Topic: logging
It goes into the manifest and builds the table of conventions in a consumer's README.
One line about the topic: логирование: уровни, структура записи
...
```
**С флагами — автоматика.** Всё передаётся сразу, вопросов не задаётся, все
недостающие поля называются разом. Это для агентов и скриптов.
```
$ convy suite add --topic logging --about "логирование: уровни" \
--prefix SLOG --title "Логирование"
```
Без терминала пустой вызов отказывает, а не виснет в ожидании ответа, которого
некому дать.
## Проверка набора
```
$ convy suite check
conventions/lang/go/logging.md
error:5 extends points at "arch/time.md" with topic "time", while the file
carries topic "logging": the layers of one topic declare one name [spread]
suite: 13 files, 8 topics, language version 1 (ru)
errors: 1, warnings: 0
```
Строка находки — `уровень:строка что не так [семейство]`. Семейств четыре:
- **manifest** — префиксы, темы, объявленные файлы, незарегистрированные файлы;
- **form** — форма правила: нумерация, блоки, модальные слова, словарь.
Проверяется в любом файле, который язык употребляет;
- **spread** — то, что относится к отъезду к потребителю: тема в шапке, ось,
`extends`, пути канона, самодостаточность нормы. Только в конвенциях;
- **links** — разрешение ссылок вида `PREFIX-N`.
Третья часть списка проверок языка — взаимоисключительность строк таблицы,
покрытие области действия, отвечает ли обоснование на «что сломается» — разбором
текста не даётся и остаётся работой читателя.
Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или
не в том контексте.
## Ступени и словарь
Слова, которыми записаны модальность и метки, — свойство версии языка и
естественного языка набора, а не самого набора. Инструмент знает их сам, и
`suite.toml` их не дублирует: достаточно `[language] version` и `lang`.
Поэтому ступень называется категорией, а не словом:
```
--modality requirement | prohibition | recommendation | not-recommended | permission
```
`prohibition` в русском наборе превращается в `**НЕ ДОЛЖЕН.**`, в английском —
в `**MUST NOT.**`. Вызывающему не нужно знать, на каком языке записан набор.
По той же причине `convy` умеет написать строку о версии языка, и созданный им
файл проходит `suite check` без единой правки.
## Чего инструмент не делает
- не сливает трёхсторонне и не разрешает конфликты: правка выше маркера
локальной части теряется, и это заявленное поведение;
- не ведёт лок-файл: копии закоммичены, ответ на «что было в прошлый раз» даёт
git;
- не хранит список подписчиков: подписка — свойство проекта;
- не переносит правки из проекта в набор: операция ручная и редкая;
- не перенумеровывает правила: номер — идентификатор, а не позиция.
+2
View File
@@ -1,3 +1,5 @@
module git.vakhrushev.me/av/convy
go 1.26.5
require github.com/BurntSushi/toml v1.6.0
+2
View File
@@ -0,0 +1,2 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+413
View File
@@ -0,0 +1,413 @@
package check_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/suite"
)
// The fixtures below stay in Russian on purpose: they are the data under test,
// written in the natural language the suite declares. Only the tool's own text
// is English.
// versionLine is the language version line. It lists the key words of the
// suite, which makes it the only place lawfully carrying modal words outside a
// rule.
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
const baseManifest = `
[language]
version = 1
description = "LANGUAGE.md"
reading = "READING.md"
[topics.live]
time = "время: хранение, зоны, форматы"
[topics.retired]
[prefixes.live]
TIME = "conventions/time.md"
[prefixes.retired]
`
const baseTime = `---
topic: time
prefix: TIME
---
# Время
Как приложение записывает моменты.
` + versionLine + `
## Правила
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
// reading stands for the short account of the language, the one document about
// it that travels into a copy. It has to name every word of the vocabulary:
// that is the whole of META-30.
const reading = `# Как читать конвенцию
Ключевые слова: ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ.
Метки: ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО, СНЯТО.
`
// files is the content of a suite: a path from the root mapped to the text of
// the file. An empty string means "no such file": that is how a test drops a
// file the base fixture provides.
type files map[string]string
func base() files {
return files{
"suite.toml": baseManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": reading,
"conventions/time.md": baseTime,
}
}
// run writes a suite into a temporary directory and runs the checks over it.
func run(t *testing.T, f files) []check.Finding {
t.Helper()
root := t.TempDir()
for name, content := range f {
if content == "" {
continue
}
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
s, err := suite.Load(root)
if err != nil {
t.Fatalf("loading the suite: %v", err)
}
return check.Suite(s).Findings()
}
func messages(findings []check.Finding) string {
var b strings.Builder
for _, f := range findings {
b.WriteString(f.Path)
b.WriteString(": ")
b.WriteString(f.Msg)
b.WriteString("\n")
}
return b.String()
}
func TestCleanSuite(t *testing.T) {
if got := run(t, base()); len(got) != 0 {
t.Fatalf("a sound suite produced findings:\n%s", messages(got))
}
}
// TestRuleHeadingIsNotAReference holds the line between a declaration and a
// reference. A heading with a foreign prefix is an error of form, and only
// that: there is nothing to resolve against the manifest, otherwise one typo
// would yield two findings about different things.
func TestRuleHeadingIsNotAReference(t *testing.T) {
f := base()
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
for _, got := range run(t, f) {
if got.Family == check.Links {
t.Errorf("a heading was parsed as a reference: %s", got.Msg)
}
}
}
// rule builds a whole rule so that tests do not repeat its form.
func rule(id, title, norm, rationale string) string {
return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n"
}
func TestChecks(t *testing.T) {
cases := []struct {
name string
setup func(files)
want string
}{{
name: "front matter prefix diverges from the manifest",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1)
},
want: "the front matter declares prefix GTIM, while the manifest assigns TIME to this file",
}, {
name: "rule heading carries a foreign prefix",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
},
want: "the rule heading uses prefix GTIM, while the file owns TIME",
}, {
name: "numbering has a gap",
setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.")
},
want: "numbering is not contiguous",
}, {
name: "a number is taken twice",
setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.")
},
want: "number TIME-1 is taken twice",
}, {
name: "rule without a rationale",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1)
},
want: "has no ПОЧЕМУ block: the rationale is mandatory",
}, {
name: "rule with neither a norm nor a stub",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1)
},
want: "has neither a norm block nor a СНЯТО stub",
}, {
name: "two norms under one number",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1)
},
want: "holds two norms (ДОЛЖЕН and СЛЕДУЕТ)",
}, {
name: "rationale precedes the norm",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1)
},
want: "the rationale precedes the norm",
}, {
name: "examples precede the rationale",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1)
},
want: "the ПРИМЕРЫ block precedes the rationale",
}, {
name: "stub of a retired rule without a date",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**СНЯТО.** Правило убрано за ненадобностью.", 1)
},
want: "carries no date of retirement",
}, {
name: "retired rule still holds a norm",
setup: func(f files) {
f["conventions/time.md"] = baseTime +
"\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n"
},
want: "still holds a norm block",
}, {
name: "no language version line",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1)
},
want: "holds no language version line",
}, {
name: "version line names a foreign version",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1)
},
want: "does not name version 1",
}, {
name: "modal word outside a rule area",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1)
},
want: "stands outside a rule area",
}, {
name: "word of a foreign vocabulary",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1)
},
want: `the word MUST belongs to the "en" vocabulary`,
}, {
name: "reference to a rule that does not exist",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри TIME-9. Без явного смещения", 1)
},
want: "reference TIME-9 does not resolve",
}, {
name: "reference to an unknown prefix",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1)
},
want: "prefix ZZZZ, which the suite manifest does not declare",
}, {
name: "topic not declared in the manifest",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1)
},
want: `topic "clocks" is not declared`,
}, {
name: "topic listed among the retired ones",
setup: func(f files) {
f["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,
`time = "время: хранение, зоны, форматы"`,
`time = "время"`+"\nlogging = \"логирование\"", 1)
},
want: `topic "logging" is declared live while the suite holds no layer of it`,
}, {
name: "declared file is missing",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1)
},
want: `prefix SLOG is assigned to the file "conventions/logging.md", which the suite does not hold`,
}, {
name: "file not registered in the manifest",
setup: func(f files) {
f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n"
},
want: "not declared in the suite manifest",
}, {
name: "prefix starts with X",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1)
f["conventions/time.md"] = strings.ReplaceAll(baseTime, "TIME", "XTIM")
},
want: "reserved for the local rules of consumers",
}, {
name: "one file with two prefixes declared",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1)
},
want: "has several prefixes declared for it",
}, {
name: "unknown manifest key",
setup: func(f files) {
f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
},
want: "is unknown to the tool",
}, {
name: "mechanization mark in convention text",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1)
},
want: "its place is the note of mechanization in the local part of the copy",
}, {
name: "canon path in convention text",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
},
want: "the text holds the canon file path",
}, {
name: "document opens below level one",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "# Время", "## Время", 1)
},
want: "opens with a level-2 heading",
}, {
name: "second level-one heading",
setup: func(f files) {
f["conventions/time.md"] = baseTime + "\n# Ещё один заголовок\n\nПроза.\n"
},
want: "holds a second level-1 heading",
}, {
name: "heading skips a level",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила", "#### Правила", 1)
},
want: "skipping a level",
}, {
name: "scenario block opens with a foreign connective",
setup: func(f files) {
f["conventions/time.md"] = baseTime +
"\nWHEN зависимость недоступна\nТОГДА запись ERROR\n"
},
want: `the scenario block opens with WHEN from the "en" vocabulary`,
}, {
name: "topic name is not usable as a file name",
setup: func(f files) {
// A bare non-ASCII key TOML rejects on its own; a quoted one
// passes straight through, which is what the check is for.
f["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/time.md"] = strings.Replace(baseTime, "topic: time", "topic: Time_Zone", 1)
},
want: "is not lower kebab-case",
}, {
name: "a second document without a topic",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1)
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
},
want: "more than one document without a topic",
}, {
name: "the short account of the language lost a word of the vocabulary",
setup: func(f files) {
f["READING.md"] = strings.Replace(reading, ", ДОПУСКАЕТСЯ", "", 1)
},
want: "does not name ДОПУСКАЕТСЯ",
}, {
name: "document without a topic carries layer keys",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1)
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
},
want: "carries the keys of a layer",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := base()
tc.setup(f)
got := messages(run(t, f))
if !strings.Contains(got, tc.want) {
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
}
})
}
}
+402
View File
@@ -0,0 +1,402 @@
package check
import (
"regexp"
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkForm checks the form of a rule by parsing text. It applies to any file
// the language employs — both the conventions and the document the suite
// governs itself by.
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
prefix := checkFilePrefix(s, d, rep)
checkHeadings(d, prefix, rep)
checkHeadingHierarchy(d, rep)
checkNumbering(d, prefix, rep)
checkRules(s, d, rep)
versionFrom, versionTo := checkVersionLine(s, d, rep)
checkModalsOutside(s, d, versionFrom, versionTo, rep)
checkForeignVocabulary(s, d, rep)
checkForeignConnectives(s, d, rep)
}
// checkHeadingHierarchy checks the ladder of headings: one title, and no level
// skipped on the way down. A rule area runs from a heading to the next heading
// of any level, so a skipped level does not merely look untidy — it moves the
// boundary the whole parsing model rests on.
func checkHeadingHierarchy(d *doc.Document, rep *Report) {
if len(d.Headings) == 0 {
return
}
if first := d.Headings[0]; first.Level != 1 {
rep.Errorf(Form, d.Path, first.Line,
"the document opens with a level-%d heading, while the title of a document is a level-1 heading", first.Level)
}
titles := 0
prev := 0
for _, h := range d.Headings {
if h.Level == 1 {
titles++
if titles > 1 {
rep.Errorf(Form, d.Path, h.Line,
"the document holds a second level-1 heading %q: the title is one, everything below it is a section", h.Text)
}
}
if prev > 0 && h.Level > prev+1 {
rep.Errorf(Form, d.Path, h.Line,
"the heading %q jumps from level %d to level %d, skipping a level", h.Text, prev, h.Level)
}
prev = h.Level
}
}
// checkForeignConnectives looks for scenario connectives of a foreign
// vocabulary. Only the start of a line counts: that is where a scenario block
// puts them, while mid-sentence AND and OR belong to SQL far more often than to
// a mixture of vocabularies.
func checkForeignConnectives(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.ForeignConnectives(s.Manifest.Language.Version, s.Manifest.Language.Lang)
if len(foreign) == 0 {
return
}
words := make([]string, 0, len(foreign))
for w := range foreign {
words = append(words, w)
}
sort.Strings(words)
d.Prose(func(n int, text string) bool {
text = strings.TrimLeft(text, " \t>-*")
for _, w := range words {
if !strings.HasPrefix(text, w) || letterAt(text, len(w)) {
continue
}
rep.Errorf(Form, d.Path, n,
"the scenario block opens with %s from the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
break
}
return true
})
}
// checkFilePrefix reconciles the prefix in the front matter with the manifest
// and returns the prefix the file is supposed to use.
func checkFilePrefix(s *suite.Suite, d *doc.Document, rep *Report) string {
declared, _ := s.Manifest.PrefixOf(d.Path)
if !d.Front.Present {
rep.Errorf(Form, d.Path, 1, "the file has no front matter, while the manifest assigns prefix %s to it", declared)
return declared
}
if d.Front.Prefix == "" {
rep.Errorf(Form, d.Path, 1, "the front matter carries no prefix key")
return declared
}
at := d.Front.At["prefix"]
if d.Front.Prefix != declared {
rep.Errorf(Form, d.Path, at,
"the front matter declares prefix %s, while the manifest assigns %s to this file", d.Front.Prefix, declared)
}
if err := manifest.ValidPrefix(d.Front.Prefix); err != nil {
rep.Errorf(Form, d.Path, at, "%s", err)
}
if s.Manifest.PrefixRetired(d.Front.Prefix) {
rep.Errorf(Form, d.Path, at, "prefix %s is listed among the retired ones", d.Front.Prefix)
}
for _, key := range d.Front.Unknown {
rep.Warnf(Form, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
}
return declared
}
// checkHeadings checks the form of rule headings: the file's own prefix, the
// third level, a period after the identifier, a title.
func checkHeadings(d *doc.Document, prefix string, rep *Report) {
for _, r := range d.Rules {
if r.Prefix != prefix {
rep.Errorf(Form, d.Path, r.Line,
"the rule heading uses prefix %s, while the file owns %s", r.Prefix, prefix)
}
if r.HeadingLevel != 3 {
rep.Errorf(Form, d.Path, r.Line,
"the heading of rule %s sits at level %d, while a rule is a third-level heading", r.ID(), r.HeadingLevel)
}
if r.Malformed != "" {
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
}
}
}
// checkNumbering checks that numbering is contiguous: from one up to the
// highest, with no gaps and no repeats (META-31). A gap is indistinguishable
// from a typo in a number and from a rule someone forgot to finish — which is
// why there is never one, and a retired rule stays as a stub.
func checkNumbering(d *doc.Document, prefix string, rep *Report) {
seen := make(map[int][]int)
for _, r := range d.Rules {
if r.Prefix != prefix {
continue
}
seen[r.Num] = append(seen[r.Num], r.Line)
}
if len(seen) == 0 {
return
}
nums := make([]int, 0, len(seen))
highest := 0
for n := range seen {
nums = append(nums, n)
highest = max(highest, n)
}
sort.Ints(nums)
for _, n := range nums {
if lines := seen[n]; len(lines) > 1 {
rep.Errorf(Form, d.Path, lines[1],
"number %s is taken twice: lines %s", ruleID(prefix, n), joinInts(lines))
}
}
var gaps []int
for n := 1; n <= highest; n++ {
if _, ok := seen[n]; !ok {
gaps = append(gaps, n)
}
}
if len(gaps) > 0 {
rep.Errorf(Form, d.Path, d.Rules[0].Line,
"numbering is not contiguous: the highest number is %d, missing %s — a retired rule stays as a stub instead of disappearing",
highest, joinInts(gaps))
}
}
var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
// checkRules checks what a rule is made of: either a norm with a rationale, or
// the stub of a retired one. Neither the norm nor the rationale is ever deleted
// (META-8, META-10).
func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
v := s.Vocab
for _, r := range d.Rules {
if retired, ok := r.Block(lang.Retired); ok {
checkRetired(d, r, retired, rep)
continue
}
norms := r.Norms()
switch len(norms) {
case 0:
rep.Errorf(Form, d.Path, r.Line,
"rule %s has neither a norm block nor a %s stub", r.ID(), v.MarkWord(lang.Retired))
case 1:
if norms[0].Rest == "" {
rep.Errorf(Form, d.Path, norms[0].Start,
"in rule %s the %s mark opens no norm: nothing follows it", r.ID(), norms[0].Word)
}
default:
rep.Errorf(Form, d.Path, norms[1].Start,
"rule %s holds two norms (%s and %s): a norm is a single statement, otherwise a violation of one half of it has no address",
r.ID(), norms[0].Word, norms[1].Word)
}
rationale, ok := r.Block(lang.Rationale)
if !ok {
rep.Errorf(Form, d.Path, r.Line,
"rule %s has no %s block: the rationale is mandatory", r.ID(), v.MarkWord(lang.Rationale))
} else if len(norms) > 0 && rationale.Start < norms[0].Start {
rep.Errorf(Form, d.Path, rationale.Start,
"in rule %s the rationale precedes the norm: the order of blocks is norm, %s, %s",
r.ID(), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
}
if examples, ok := r.Block(lang.Examples); ok {
switch {
case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start:
rep.Errorf(Form, d.Path, examples.Start,
"in rule %s the %s block opens the rule: the order of blocks is norm, %s, %s",
r.ID(), v.MarkWord(lang.Examples), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
case ok && rationale.Start > examples.Start:
rep.Errorf(Form, d.Path, examples.Start,
"in rule %s the %s block precedes the rationale: the requirement first, then the reason, then the illustration",
r.ID(), v.MarkWord(lang.Examples))
}
}
}
}
// checkRetired checks the stub of a retired rule: a date and a reason.
func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
if len(r.Norms()) > 0 {
rep.Errorf(Form, d.Path, retired.Start,
"retired rule %s still holds a norm block: the stub replaces the norm together with the rationale", r.ID())
}
if !dateRe.MatchString(d.Line(retired.Start)) {
rep.Errorf(Form, d.Path, retired.Start,
"the stub of rule %s carries no date of retirement", r.ID())
}
if strings.TrimSpace(retired.Rest) == "" {
rep.Errorf(Form, d.Path, retired.Start,
"the stub of rule %s carries no reason for retirement", r.ID())
}
}
// checkVersionLine looks for the language version line in the introductory
// prose and returns the bounds of the paragraph carrying it.
//
// The line lists the key words of the suite and carries the rule of capitals
// itself — which makes it the only place outside rules where modal words are
// lawful.
func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(s, 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)
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)
}
return p.Start, p.End
}
// versionParagraph looks in the introductory prose for the paragraph carrying
// the language version line: the one listing every key word of the suite. It
// reports nothing — checkVersionLine speaks about its absence, and speaking
// twice helps no one.
func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
from, to := d.Preamble()
words := s.Vocab.Words()
for _, p := range d.Paragraphs(from, to) {
if containsAll(p.Text(), words) {
return p, true
}
}
return doc.Paragraph{}, false
}
// checkModalsOutside looks for capitalized modal words outside rule areas. An
// area runs from the heading of a rule to the next heading; everything else is
// prose, and prose is never a norm.
func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(s.Vocab)
d.Prose(func(n int, text string) bool {
if d.InRule(n) || n >= versionFrom && n <= versionTo {
return true
}
for _, w := range words {
if !containsWord(text, w) {
continue
}
rep.Errorf(Form, d.Path, n,
"the modal word %s stands outside a rule area: capitalized spelling is normative, and prose cannot hold it", w)
break
}
return true
})
}
// checkForeignVocabulary looks for words of another vocabulary of the same
// language version. There is one vocabulary per suite: two ways of writing the
// same requirement double every check.
func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang)
if len(foreign) == 0 {
return
}
words := make([]string, 0, len(foreign))
for w := range foreign {
words = append(words, w)
}
sort.Strings(words)
d.Prose(func(n int, text string) bool {
for _, w := range words {
if containsWord(text, w) {
rep.Errorf(Form, d.Path, n,
"the word %s belongs to the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
}
}
return true
})
}
func modalWords(v lang.Vocabulary) []string {
out := make([]string, 0, len(v.Modals))
for w := range v.Modals {
out = append(out, w)
}
sort.Strings(out)
return out
}
func containsAll(text string, words []string) bool {
for _, w := range words {
if !strings.Contains(text, w) {
return false
}
}
return true
}
// containsWord looks for a word as a whole: "MUSTARD" is not the word "MUST",
// while "MUST." is.
func containsWord(text, word string) bool {
for i := 0; ; {
j := strings.Index(text[i:], word)
if j < 0 {
return false
}
start := i + j
end := start + len(word)
if !letterBefore(text, start) && !letterAt(text, end) {
return true
}
i = start + len(word)
if i >= len(text) {
return false
}
}
}
func containsNumber(text, number string) bool {
for i := 0; ; {
j := strings.Index(text[i:], number)
if j < 0 {
return false
}
start := i + j
end := start + len(number)
if !digitBefore(text, start) && !digitAt(text, end) {
return true
}
i = end
if i >= len(text) {
return false
}
}
}
func ruleID(prefix string, num int) string {
return prefix + "-" + strconv.Itoa(num)
}
func joinInts(nums []int) string {
parts := make([]string, len(nums))
for i, n := range nums {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, ", ")
}
+292
View File
@@ -0,0 +1,292 @@
package check_test
import (
"strings"
"testing"
)
// A suite with two layers of one topic: a base architectural layer and a
// language layer on top of it. Everything about axes, extends and the boundary
// of a self-sufficient norm is checked here — a single layer cannot express it.
const layeredManifest = `
[language]
version = 1
description = "LANGUAGE.md"
reading = "READING.md"
[topics.live]
time = "время"
logging = "логирование"
[topics.retired]
[prefixes.live]
TIME = "conventions/arch/time.md"
GTIM = "conventions/lang/go/time.md"
SLOG = "conventions/arch/logging.md"
[prefixes.retired]
`
const archTime = `---
topic: time
prefix: TIME
---
# Время
Как приложение записывает моменты.
` + versionLine + `
## Правила
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
const goTime = `---
topic: time
prefix: GTIM
lang: go
extends: arch/time.md
---
# Время: реализация на Go
Как требования базового слоя выполняются в Go-коде.
` + versionLine + `
## Правила
### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из store.Now().
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
`
const archLogging = `---
topic: logging
prefix: SLOG
---
# Логирование
Как приложение пишет записи.
` + versionLine + `
## Правила
### SLOG-1. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
`
func layered() files {
return files{
"suite.toml": layeredManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": reading,
"conventions/arch/time.md": archTime,
"conventions/lang/go/time.md": goTime,
"conventions/arch/logging.md": archLogging,
}
}
func TestLayeredSuiteIsClean(t *testing.T) {
if got := run(t, layered()); len(got) != 0 {
t.Fatalf("a sound layered suite produced findings:\n%s", messages(got))
}
}
func TestLayeredChecks(t *testing.T) {
cases := []struct {
name string
setup func(files)
want string
}{{
name: "axis in the front matter diverges from the path",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
},
want: `the path puts the file on axis lang=go, while the front matter declares lang="python"`,
}, {
name: "extends leads into a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/logging.md", 1)
},
want: `with topic "logging", while the file carries topic "time"`,
}, {
name: "extends leads into a file that does not exist",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/clocks.md", 1)
},
want: `and the suite holds no such file`,
}, {
name: "topic with two layers lacking axis keys",
setup: func(f files) {
f["suite.toml"] = strings.Replace(layeredManifest,
`GTIM = "conventions/lang/go/time.md"`,
`GTIM = "conventions/second/time.md"`, 1)
f["conventions/lang/go/time.md"] = ""
f["conventions/second/time.md"] = strings.Replace(
strings.Replace(goTime, "lang: go\n", "", 1),
"extends: arch/time.md\n", "", 1)
},
want: "more than one layer without axis keys",
}, {
name: "norm references the prefix of a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
},
want: `refers to SLOG-1 from the foreign topic "logging"`,
}, {
name: "norm references a non-base layer of its own topic",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
},
want: "points at a layer of this topic that is not the base one",
}, {
name: "rationale of the base layer references a layer above it",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Без явного смещения не видно зоны; в Go это выражает GTIM-1.", 1)
},
want: "points at a layer of this topic that is not the base one",
}, {
name: "a section outside any rule references a layer above",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Связано\n\nРеализация на Go — GTIM-1.\n"
},
want: "points at a layer of this topic that is not the base one",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := layered()
tc.setup(f)
got := messages(run(t, f))
if !strings.Contains(got, tc.want) {
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
}
})
}
}
// TestNoFalsePositives gathers the cases where a check must stay silent. A
// false positive costs more here than a miss: a check that goes red on a sound
// file gets switched off altogether.
func TestNoFalsePositives(t *testing.T) {
cases := []struct {
name string
setup func(files)
}{{
name: "an identifier in backticks is a sample of notation, not a reference",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"Без явного смещения",
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
},
}, {
name: "a modal word inside a fenced code block",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
},
}, {
name: "an uppercase SQL keyword in a code sample",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1)
},
}, {
name: "a language layer's norm references the base layer of its own topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
},
}, {
name: "a mention of a step in the rationale is not a second norm",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
},
}, {
name: "a stub of a retired rule with a date and a reason",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Ширина следует из TIME-1 и отдельного правила не требует.\n"
},
}, {
name: "a scenario block in the suite's own vocabulary",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Стык правил\n\nКОГДА зависимость недоступна И ретраи исчерпаны\nТОГДА запись делается один раз (TIME-1)\n"
},
}, {
name: "an uppercase SQL connective mid-sentence outside a fence",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Условие `WHERE a AND b OR c` сортировку не спасает.", 1)
},
}, {
name: "a rationale references a foreign topic, which META-20 allows",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.",
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC; тот же довод стоит за SLOG-1.", 1)
},
}, {
name: "a section outside any rule references a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = goTime +
"\n## Связано\n\nКонвенция logging, правило SLOG-1.\n"
},
}, {
name: "a layer references the base layer of its topic outside a norm",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.",
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC, чего и требует TIME-1.", 1)
},
}, {
name: "the single document without a topic is the one the suite governs itself by",
setup: func(f files) {
f["suite.toml"] = strings.Replace(layeredManifest,
`SLOG = "conventions/arch/logging.md"`,
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1)
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
},
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := layered()
tc.setup(f)
if got := run(t, f); len(got) != 0 {
t.Fatalf("a check fired where it must not:\n%s", messages(got))
}
})
}
}
+121
View File
@@ -0,0 +1,121 @@
package check
import (
"regexp"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/suite"
)
// refRe catches a rule identifier: four uppercase Latin letters, a hyphen, a
// number and an optional table row number.
var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`)
// Ref is a reference to a rule found in the text.
type Ref struct {
Prefix string
Num int
Sub int
Line int
Text string
}
// refsIn collects the references in a range of lines. Inline code is cut out:
// inside backticks an identifier stands as a sample of the notation rather than
// as a reference to an assertion — otherwise the line "a rule is referred to by
// its identifier (`SLOG-27`)" would demand that rule SLOG-27 exist.
//
// Rule headings are skipped: a heading declares a rule instead of referring to
// one, and there is nothing to resolve against the manifest.
func refsIn(d *doc.Document, from, to int) []Ref {
heading := make(map[int]bool, len(d.Rules))
for _, r := range d.Rules {
heading[r.Line] = true
}
var out []Ref
for n := from; n <= to; n++ {
if d.Fenced(n) || heading[n] {
continue
}
out = append(out, refsInLine(n, doc.StripInline(d.Line(n)))...)
}
return out
}
func refsInLine(n int, text string) []Ref {
var out []Ref
for _, m := range refRe.FindAllStringSubmatch(text, -1) {
num, err := strconv.Atoi(m[2])
if err != nil {
continue
}
ref := Ref{Prefix: m[1], Num: num, Line: n, Text: m[0]}
if m[3] != "" {
ref.Sub, _ = strconv.Atoi(m[3])
}
out = append(out, ref)
}
return out
}
// checkLinks verifies that every reference resolves. An unresolved identifier
// is always an error: with stubs standing in for retired rules there is no
// third outcome — a reference leads either to a rule or to the explanation of
// why it was retired (META-31, META-32).
func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) {
for _, ref := range refsIn(d, d.Body, d.Len()) {
if strings.HasPrefix(ref.Prefix, "X") {
// A consumer's prefix: the local rules of a foreign repository
// are invisible to the suite and are not to be resolved.
continue
}
if s.Manifest.PrefixRetired(ref.Prefix) {
rep.Errorf(Links, d.Path, ref.Line,
"reference %s points at retired prefix %s", ref.Text, ref.Prefix)
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok {
if _, declared := s.Manifest.PathOf(ref.Prefix); declared {
// The file is declared but was not read — the manifest check
// has already said so, and saying it twice helps no one.
continue
}
rep.Errorf(Links, d.Path, ref.Line,
"reference %s points at prefix %s, which the suite manifest does not declare", ref.Text, ref.Prefix)
continue
}
rule, ok := ruleByNum(target, ref.Num)
if !ok {
rep.Errorf(Links, d.Path, ref.Line,
"reference %s does not resolve: %s holds no rule numbered %d", ref.Text, target.Path, ref.Num)
continue
}
if ref.Sub > 0 && !mentions(target, rule, ref.Text) {
rep.Errorf(Links, d.Path, ref.Line,
"reference %s does not resolve: the area of %s holds no such row", ref.Text, rule.ID())
}
}
}
// mentions reports whether the text of a reference occurs inside the area of a
// rule. That is how a table row number is checked: the row itself carries it.
func mentions(d *doc.Document, rule doc.Rule, text string) bool {
for n := rule.Line; n <= rule.End; n++ {
if strings.Contains(d.Line(n), text) {
return true
}
}
return false
}
func ruleByNum(d *doc.Document, num int) (doc.Rule, bool) {
for _, r := range d.Rules {
if r.Num == num {
return r, true
}
}
return doc.Rule{}, false
}
+106
View File
@@ -0,0 +1,106 @@
// Package check runs the integrity checks of a suite.
//
// The split into families is taken from the language and kept in the code: the
// form of a rule is checked in any file the language employs; spread only in
// convention files, because those checks are about a document travelling to a
// consumer. The third part of the list — rows of a table being mutually
// exclusive, the scope being covered, a norm being self-sufficient — is not
// here: it does not yield to parsing text and stays the reader's work.
package check
import (
"fmt"
"sort"
)
// Severity tells an error from a warning. An error is a violation named by a
// rule of the suite; a warning is something worth a look.
type Severity int
const (
Error Severity = iota + 1
Warning
)
func (s Severity) String() string {
if s == Warning {
return "warning"
}
return "error"
}
// Family is the family of checks a finding came from.
type Family string
const (
Manifest Family = "manifest"
Form Family = "form"
Spread Family = "spread"
Links Family = "links"
)
// Finding is a single finding.
type Finding struct {
Severity Severity
Family Family
// Path is the path of the file from the root of the suite; empty when the
// finding is about the suite as a whole.
Path string
// Line is the line of the file; zero when the finding is not bound to one.
Line int
Msg string
}
// Report accumulates the findings of one run.
type Report struct {
findings []Finding
}
// Errorf records an error.
func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) {
r.add(Error, f, path, line, format, args...)
}
// Warnf records a warning.
func (r *Report) Warnf(f Family, path string, line int, format string, args ...any) {
r.add(Warning, f, path, line, format, args...)
}
func (r *Report) add(s Severity, f Family, path string, line int, format string, args ...any) {
r.findings = append(r.findings, Finding{
Severity: s,
Family: f,
Path: path,
Line: line,
Msg: fmt.Sprintf(format, args...),
})
}
// Findings hands over the findings ordered by file and line.
func (r *Report) Findings() []Finding {
out := make([]Finding, len(r.findings))
copy(out, r.findings)
sort.SliceStable(out, func(i, j int) bool {
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Line < out[j].Line
})
return out
}
// Errors counts the findings of error severity.
func (r *Report) Errors() int {
n := 0
for _, f := range r.findings {
if f.Severity == Error {
n++
}
}
return n
}
// Warnings counts the warnings.
func (r *Report) Warnings() int {
return len(r.findings) - r.Errors()
}
+254
View File
@@ -0,0 +1,254 @@
package check
import (
"path"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkSpread checks what bears on a document travelling to a consumer. It
// applies to convention files only: the document the suite governs itself by
// travels nowhere, and a canon path inside it is lawful.
func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkTopic(s, d, rep)
checkAxis(d, rep)
checkExtends(s, d, rep)
checkMechanized(s, d, rep)
checkCanonPaths(s, d, rep)
checkForeignTopicInNorm(s, d, rep)
checkOwnTopicLayerRefs(s, d, rep)
}
// checkTopic reconciles the topic from the front matter with the manifest
// (META-28, META-29).
func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) {
topic := d.Front.Topic
at := d.Front.At["topic"]
switch {
case s.Manifest.TopicRetired(topic):
rep.Errorf(Spread, d.Path, at,
"topic %q is listed among the retired ones: a retired name is never handed to another topic", topic)
case !s.Manifest.TopicLive(topic):
rep.Errorf(Spread, d.Path, at,
"topic %q is not declared in the suite manifest", topic)
}
}
// checkAxis reconciles the declared axis with the path of the file. An axis is
// declared in the front matter rather than derived from the path (META-38); but
// once axis directories are in use, a divergence means the file moved and the
// front matter did not.
func checkAxis(d *doc.Document, rep *Report) {
parts := strings.Split(path.Dir(d.Path), "/")
for i := 0; i+1 < len(parts); i++ {
var declared, key string
switch parts[i] {
case "lang":
declared, key = d.Front.Lang, "lang"
case "stack":
declared, key = d.Front.Stack, "stack"
default:
continue
}
if declared == parts[i+1] {
continue
}
at := d.Front.At[key]
if at == 0 {
at = d.Front.At["prefix"]
}
rep.Errorf(Spread, d.Path, at,
"the path puts the file on axis %s=%s, while the front matter declares %s=%q", key, parts[i+1], key, declared)
}
}
// checkExtends verifies that the declared base exists and belongs to the same
// topic. The key documents the tie between layers for a human — and
// documentation that lies is worse than none.
func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
if d.Front.Extends == "" {
return
}
at := d.Front.At["extends"]
target, others := resolveExtends(s, d, d.Front.Extends)
if len(others) > 1 {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds several files it could mean (%v): give the path from the root of the suite",
d.Front.Extends, others)
return
}
if target == nil {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds no such file", d.Front.Extends)
return
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, at,
"extends points at %q with topic %q, while the file carries topic %q: the layers of one topic declare one name",
d.Front.Extends, target.Front.Topic, d.Front.Topic)
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, which is not a base layer: it declares an axis", d.Front.Extends)
}
}
// resolveExtends looks up the document at the path written in extends.
//
// The path may be given from the root of the suite or from the directory of
// conventions, so an exact match is tried first and a tail match second. A tail
// match can fit several files at once — two layers of one topic often share a
// file name — so every candidate is returned and the caller reports the
// ambiguity instead of picking by the order documents happen to be loaded in.
// The document doing the extending is never its own base.
func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Document, []string) {
ref = path.Clean(strings.TrimPrefix(ref, "./"))
var candidates []*doc.Document
for _, d := range s.Docs {
if d == from {
continue
}
if d.Path == ref {
return d, []string{d.Path}
}
if strings.HasSuffix(d.Path, "/"+ref) {
candidates = append(candidates, d)
}
}
paths := make([]string, len(candidates))
for i, d := range candidates {
paths[i] = d.Path
}
sort.Strings(paths)
if len(candidates) == 1 {
return candidates[0], paths
}
return nil, paths
}
// namesSuiteFile reports whether a candidate written in the text names a file
// the suite holds. Unlike an extends key it needs no single answer: a path that
// fits several files of the canon is a path all the same.
func namesSuiteFile(s *suite.Suite, candidate string) bool {
if s.Exists(candidate) {
return true
}
for _, d := range s.Docs {
if d.Path == candidate || strings.HasSuffix(d.Path, "/"+candidate) {
return true
}
}
return false
}
// checkMechanized looks for the mark of mechanization in the text of a
// convention. Whether a norm is mechanized is a property of a repository rather
// than of the suite, so the place of the mark is the local part of the copy
// (META-7).
func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
word := s.Vocab.MarkWord(lang.Mechanized)
if word == "" {
return
}
version, hasVersion := versionParagraph(s, d)
d.Prose(func(n int, text string) bool {
if hasVersion && n >= version.Start && n <= version.End {
return true
}
if containsWord(text, word) {
rep.Errorf(Spread, d.Path, n,
"the %s mark stands in the text of a convention: its place is the note of mechanization in the local part of the copy", word)
}
return true
})
}
// mdPathRe catches what looks like a path to a file of the suite.
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
// checkCanonPaths looks for the path of a canon file in the text of a
// convention (META-21). In a consumer's repository a convention lies assembled,
// the layers of one topic are sections of one file, and the path
// `lang/go/logging.md` does not exist there: a reference to it dies on assembly,
// and dies in silence — the text stays coherent.
//
// Inline code is not cut out here: a path in backticks is still a path.
func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) {
if !namesSuiteFile(s, candidate) {
continue
}
rep.Errorf(Spread, d.Path, n,
"the text holds the canon file path %q: refer by the name of a topic or the identifier of a rule", candidate)
}
}
}
// checkForeignTopicInNorm looks for the prefix of a foreign topic inside a norm
// block (META-20). The norm of a rule must be executable holding this one file:
// a repository subscribes to an arbitrary subset of the conventions, and it has
// no dependency graph by construction.
//
// Outside a norm such a reference is lawful and stays unchecked. A rationale
// that loses its addressee degrades honestly — the cross-check goes, the
// meaning stays — while a norm missing a neighbouring file becomes unenforceable
// in silence.
func checkForeignTopicInNorm(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d)
for _, r := range d.Rules {
for _, norm := range r.Norms() {
for _, ref := range refsIn(d, norm.Start, norm.End) {
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic == d.Front.Topic {
continue
}
rep.Errorf(Spread, d.Path, ref.Line,
"the norm of %s refers to %s from the foreign topic %q: only the rationale looks outward",
r.ID(), ref.Text, target.Front.Topic)
}
}
}
}
// checkOwnTopicLayerRefs holds the direction of references inside one topic:
// only the base layer may be pointed at, and from anywhere in the document
// rather than from the norm alone.
//
// Guaranteed to stand in the copy is exactly one layer of a topic — the base
// one; it goes in whatever language and stack were chosen (META-24). The order
// of assembly, base then language then stack, is the order of concatenation and
// not a chain of dependency: a consumer is free to take stack=htmx with
// lang=python, so a stack layer has no claim on a language layer either.
//
// The reason reaches past dangling links. The base layer is the text that has
// to hold for every language and every stack; a rationale of its that needs a
// rule of the Go layer to explain itself is the specific leaking into the
// shared, and the reasoning belongs in that layer instead. The base layer is
// therefore left no way to point at a layer above it at all — by design.
func checkOwnTopicLayerRefs(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d)
for _, ref := range refsIn(d, d.Body, d.Len()) {
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic != d.Front.Topic || !target.Front.Axis() {
continue
}
rep.Errorf(Spread, d.Path, ref.Line,
"the reference %s points at a layer of this topic that is not the base one: only the base layer is guaranteed to stand in a copy, and a rule that needs this one belongs in that layer",
ref.Text)
}
}
+220
View File
@@ -0,0 +1,220 @@
package check
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Suite runs every check of a suite and returns the report.
func Suite(s *suite.Suite) *Report {
rep := &Report{}
checkManifest(s, rep)
checkTopicNames(s, rep)
checkBaseLayers(s, rep)
checkSelfGoverning(s, rep)
checkReadingVocabulary(s, rep)
for _, d := range s.Docs {
checkForm(s, d, rep)
checkLinks(s, d, rep)
if d.Front.Topic != "" {
checkSpread(s, d, rep)
}
}
return rep
}
// checkManifest checks the manifest itself: the shape of prefixes, live and
// retired not overlapping, the declared files being present and no undeclared
// ones lying around.
func checkManifest(s *suite.Suite, rep *Report) {
m := s.Manifest
for _, key := range m.Undecoded {
rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key)
}
for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) {
rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name)
}
}
byPath := make(map[string][]string)
for _, prefix := range m.LivePrefixes() {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
}
if m.PrefixRetired(prefix) {
rep.Errorf(Manifest, manifest.Name, 0,
"prefix %s is listed both live and retired: a retired one is never reissued", prefix)
}
path := m.Prefixes.Live[prefix]
byPath[path] = append(byPath[path], prefix)
}
for _, path := range sortedKeys(byPath) {
if prefixes := byPath[path]; len(prefixes) > 1 {
sort.Strings(prefixes)
rep.Errorf(Manifest, manifest.Name, 0,
"the file %q has several prefixes declared for it (%v): a prefix belongs to one file", path, prefixes)
}
}
for prefix := range m.Prefixes.Retired {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "among the retired ones: %s", err)
}
}
for _, prefix := range sortedKeys(s.Missing) {
rep.Errorf(Manifest, manifest.Name, 0,
"prefix %s is assigned to the file %q, which the suite does not hold", prefix, s.Missing[prefix])
}
for _, path := range s.Unregistered {
rep.Errorf(Manifest, path, 1,
"the file is written in the conventions language yet not declared in the suite manifest: for the suite it does not exist")
}
for _, err := range s.Broken {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
}
for _, topic := range m.LiveTopics() {
if m.TopicRetired(topic) {
rep.Errorf(Manifest, manifest.Name, 0,
"topic %q is listed both live and retired", topic)
}
if len(s.Layers(topic)) == 0 {
rep.Errorf(Manifest, manifest.Name, 0,
"topic %q is declared live while the suite holds no layer of it: a topic lives as long as at least one layer does", topic)
}
}
}
var (
// topicNameRe is the hard bound: a topic name reaches the file system of
// a consumer, so it has to be usable as a file name — Latin letters,
// digits and the plain separators.
topicNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// kebabRe is the recommended shape, and only a recommendation.
kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
)
// checkTopicNames checks the shape of topic names. A name lands both in a
// consumer's file system and in their manifest, which is where the hard bound
// comes from; lower kebab-case on top of that is a recommendation and speaks as
// a warning.
func checkTopicNames(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() {
switch {
case !topicNameRe.MatchString(topic):
rep.Errorf(Manifest, manifest.Name, 0,
"the topic name %q is not usable as a file name: a name is written in Latin letters and travels into the file system of a consumer", topic)
case !kebabRe.MatchString(topic):
rep.Warnf(Manifest, manifest.Name, 0,
"the topic name %q is not lower kebab-case, which is the recommended shape", topic)
}
}
}
// checkSelfGoverning guards the document without a topic.
//
// A topic-less document is the one the suite governs itself by: it travels
// nowhere and cannot be subscribed to, so it gets no spread checks. 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.
func checkSelfGoverning(s *suite.Suite, rep *Report) {
var topicless []string
for _, d := range s.Docs {
if d.Front.Topic != "" {
continue
}
topicless = append(topicless, d.Path)
if 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)
}
}
}
// checkReadingVocabulary checks that the short account of the language names
// every word of the vocabulary (META-30).
//
// The full account stays with the author of the suite, while the rules are
// applied by the reader of a copy — a person or an agent in a foreign
// repository who holds the short one and nothing else. Let the two drift apart
// and that reader starts reading the words by an older version: ДОПУСКАЕТСЯ
// turns back into an everyday "you may", a departure from ДОЛЖЕН stops
// demanding a record. Exactly what the words were introduced for fails, and it
// fails in silence.
func checkReadingVocabulary(s *suite.Suite, rep *Report) {
name := s.Manifest.Language.Reading
if name == "" {
// A suite that has not written the document yet; `suite init` says so
// among the next steps, and repeating it on every run is noise.
return
}
body, err := os.ReadFile(filepath.Join(s.Root, filepath.FromSlash(name)))
if err != nil {
// The missing file is reported by the manifest check already.
return
}
var missing []string
for _, word := range s.Vocab.Words() {
if !containsWord(string(body), word) {
missing = append(missing, word)
}
}
if len(missing) > 0 {
rep.Errorf(Form, name, 0,
"the short account of the language does not name %s: it is the only key to the text of a rule a reader of a copy holds, and a word missing from it is a word read by whatever version the reader remembers",
strings.Join(missing, ", "))
}
}
// checkBaseLayers checks that a topic holds no more than one layer without axis
// keys. The base layer is the only one of its kind: it reaches every copy, and a
// second such layer would mean two base texts in one assembled file.
func checkBaseLayers(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() {
var base []string
for _, d := range s.Layers(topic) {
if !d.Front.Axis() {
base = append(base, d.Path)
}
}
if len(base) > 1 {
sort.Strings(base)
for _, path := range base[1:] {
rep.Errorf(Spread, path, 1,
"topic %q holds more than one layer without axis keys: the base layer is the only one of its kind, and the candidates are %v",
topic, base)
}
}
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+41
View File
@@ -0,0 +1,41 @@
package check
import (
"unicode"
"unicode/utf8"
)
// letterAt reports whether offset i holds a letter or a digit. Cyrillic takes
// two bytes in UTF-8, so a single byte is not enough to decide.
func letterAt(text string, i int) bool {
if i >= len(text) {
return false
}
r, _ := utf8.DecodeRuneInString(text[i:])
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// letterBefore reports whether a letter or a digit stands before offset i.
func letterBefore(text string, i int) bool {
if i <= 0 {
return false
}
r, _ := utf8.DecodeLastRuneInString(text[:i])
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
func digitAt(text string, i int) bool {
if i >= len(text) {
return false
}
r, _ := utf8.DecodeRuneInString(text[i:])
return unicode.IsDigit(r)
}
func digitBefore(text string, i int) bool {
if i <= 0 {
return false
}
r, _ := utf8.DecodeLastRuneInString(text[:i])
return unicode.IsDigit(r)
}
+138
View File
@@ -0,0 +1,138 @@
// Package cli lays out the commands of the tool.
//
// The depth of a command reflects how often it runs and whom it addresses:
// project commands run in every repository and often, tending a suite runs in
// one repository and rarely. That is why `check` stays at the top level and
// whatever makes no sense in a project goes under `suite`. There are no
// synonyms: `convy suite pull` is not introduced next to `convy pull`.
package cli
import (
"fmt"
"io"
"os"
)
// ExitCode is the exit status of the process.
type ExitCode int
const (
// OK means the check passed and the work is done.
OK ExitCode = 0
// Failed means the check found errors.
Failed ExitCode = 1
// Usage means the command was typed wrong or run in the wrong context.
Usage ExitCode = 2
)
// Env is the environment of a run. It is pulled out so that commands can be
// tested without a process.
type Env struct {
Dir string
In io.Reader
Out io.Writer
Err io.Writer
// Interactive says whether input comes from a terminal. A command that
// asks questions refuses to start without one rather than blocking on an
// answer nobody is there to give.
Interactive bool
}
// Run parses the arguments and executes the command.
func Run(env Env, args []string) ExitCode {
if len(args) == 0 {
usage(env.Out)
return Usage
}
switch args[0] {
case "suite":
return runSuite(env, args[1:])
case "add", "pull", "list", "check":
fmt.Fprintf(env.Err, "the %q command is not implemented yet\n", args[0])
return Usage
case "help", "-h", "--help":
usage(env.Out)
return OK
default:
fmt.Fprintf(env.Err, "unknown command %q\n\n", args[0])
usage(env.Err)
return Usage
}
}
func runSuite(env Env, args []string) ExitCode {
if len(args) == 0 {
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, rule, retire, list or check")
return Usage
}
switch args[0] {
case "check":
return runSuiteCheck(env, args[1:])
case "init":
return runSuiteInit(env, args[1:])
case "add":
return runSuiteAdd(env, args[1:])
case "rule":
return runSuiteRule(env, args[1:])
case "retire":
return runSuiteRetire(env, args[1:])
case "list":
return runSuiteList(env, args[1:])
default:
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
return Usage
}
}
// usage prints the help grouped under headings: a flat list hides the levels.
func usage(w io.Writer) {
fmt.Fprint(w, `convy — tending development conventions.
In a project:
convy 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)
In a suite:
convy suite init start a suite: a directory and a manifest
convy suite add add a convention: a file, a topic and a prefix
convy suite rule add a rule: the next number, the blocks in order
convy suite retire retire a rule, a convention or a topic — never reusing it
convy suite list what the suite holds, and what a component would take
convy suite check suite integrity: prefixes, topics, axes, links, form
The commands that change something run in two modes. Bare, they ask for every
field with a hint attached — that mode is for a person. With flags, they take
everything at once and ask nothing — that mode is for agents and scripts.
`)
}
// Main is the entry point of the process.
func Main() int {
dir, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "cannot determine the current directory:", err)
return int(Usage)
}
env := Env{
Dir: dir,
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
Interactive: terminal(os.Stdin),
}
return int(Run(env, os.Args[1:]))
}
// terminal reports whether a file is a character device, which is as close as
// the standard library gets to asking whether a person is on the other end.
func terminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
+251
View File
@@ -0,0 +1,251 @@
package cli_test
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/suite"
)
// run executes a command the way the process does, with input and output in
// hand. Interactive says whether a terminal is pretended to be there.
func run(t *testing.T, dir, input string, interactive bool, args ...string) (cli.ExitCode, string) {
t.Helper()
var out, errOut bytes.Buffer
env := cli.Env{
Dir: dir,
In: strings.NewReader(input),
Out: &out,
Err: &errOut,
Interactive: interactive,
}
code := cli.Run(env, args)
return code, out.String() + errOut.String()
}
func read(t *testing.T, parts ...string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(parts...))
if err != nil {
t.Fatal(err)
}
return string(body)
}
// checkClean asserts that the suite the commands built passes every check.
// This is the invariant worth the most: what the tool writes, the tool accepts.
func checkClean(t *testing.T, root string) {
t.Helper()
s, err := suite.Load(root)
if err != nil {
t.Fatalf("loading the suite the commands built: %v", err)
}
rep := check.Suite(s)
if rep.Errors() > 0 || rep.Warnings() > 0 {
var b strings.Builder
for _, f := range rep.Findings() {
fmt.Fprintf(&b, " %s: %s\n", f.Path, f.Msg)
}
t.Fatalf("the suite the commands built does not check clean:\n%s", b.String())
}
}
func TestInitAndAddInAutomaticMode(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK {
t.Fatalf("suite init returned %d: %s", code, out)
}
if code, out := run(t, root, "", false, "suite", "add",
"--topic", "time", "--about", "время: хранение и форматы",
"--prefix", "TIME", "--title", "Время",
"--intro", "Как приложение записывает моменты."); code != cli.OK {
t.Fatalf("suite add returned %d: %s", code, out)
}
if code, out := run(t, root, "", false, "suite", "add",
"--topic", "time", "--prefix", "GTIM", "--lang", "go",
"--title", "Время: реализация на Go"); code != cli.OK {
t.Fatalf("suite add of a layer returned %d: %s", code, out)
}
base := read(t, root, "conventions/time.md")
for _, want := range []string{"topic: time", "prefix: TIME", "# Время", "ДОЛЖЕН", "версии 1"} {
if !strings.Contains(base, want) {
t.Errorf("the base layer lost %q:\n%s", want, base)
}
}
if strings.Contains(base, "extends:") {
t.Errorf("the base layer got an extends key:\n%s", base)
}
layer := read(t, root, "conventions/lang/go/time.md")
for _, want := range []string{"lang: go", "extends: conventions/time.md"} {
if !strings.Contains(layer, want) {
t.Errorf("the language layer lost %q:\n%s", want, layer)
}
}
checkClean(t, root)
}
// The dialogue asks in the order the fields are declared, and a new topic gets
// one extra question about what it is for.
func TestAddInInteractiveMode(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK {
t.Fatalf("suite init returned %d: %s", code, out)
}
answers := strings.Join([]string{
"logging", // topic
"логирование: уровни", // one line about a topic new to the suite
"SLOG", // prefix
"Логирование", // title
"", // language axis: the base layer
"", // stack axis
"Как приложение пишет записи.", // introductory prose
"", // path: the default offered
}, "\n") + "\n"
code, out := run(t, root, answers, true, "suite", "add")
if code != cli.OK {
t.Fatalf("the dialogue returned %d: %s", code, out)
}
if !strings.Contains(out, "One line about the topic") {
t.Errorf("a topic new to the suite was not asked about:\n%s", out)
}
body := read(t, root, "conventions/logging.md")
for _, want := range []string{"topic: logging", "prefix: SLOG", "# Логирование", "Как приложение пишет записи."} {
if !strings.Contains(body, want) {
t.Errorf("the file lost %q:\n%s", want, body)
}
}
if !strings.Contains(read(t, root, "suite.toml"), `logging = "логирование: уровни"`) {
t.Error("the topic did not reach the manifest")
}
checkClean(t, root)
}
// A known topic is described already, so the dialogue skips that question.
func TestInteractiveSkipsTheQuestionAboutAKnownTopic(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
answers := "time\nGTIM\nВремя на Go\ngo\n\n\n\n"
code, out := run(t, root, answers, true, "suite", "add")
if code != cli.OK {
t.Fatalf("the dialogue returned %d: %s", code, out)
}
if strings.Contains(out, "One line about the topic") {
t.Errorf("a known topic was asked about again:\n%s", out)
}
if !strings.Contains(out, "the topic is known") {
t.Errorf("the dialogue did not say the topic is known:\n%s", out)
}
checkClean(t, root)
}
// Without a terminal a bare command must refuse rather than block on an answer
// nobody is there to give.
func TestBareCommandRefusesWithoutATerminal(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
code, out := run(t, root, "", false, "suite", "add")
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
if !strings.Contains(out, "no terminal") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// Automatic mode names every missing field at once: being sent back one flag at
// a time is the worst way to learn what a command wants.
func TestAutomaticModeNamesEveryMissingField(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
code, out := run(t, root, "", false, "suite", "add", "--topic", "time")
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
for _, want := range []string{"--prefix", "--title"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not name %s:\n%s", want, out)
}
}
}
func TestAddRefusesWhatTheSuiteAlreadyHolds(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
cases := []struct {
name string
args []string
want string
}{{
name: "a prefix already taken",
args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "TIME", "--title", "Конфигурация"},
want: "already taken",
}, {
name: "a prefix on the letter reserved for consumers",
args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "XCFG", "--title", "Конфигурация"},
want: "reserved for the local rules of consumers",
}, {
name: "a second base layer of one topic",
args: []string{"--topic", "time", "--prefix", "TIMB", "--title", "Время снова", "--path", "conventions/time-again.md"},
want: "already holds a base layer",
}, {
name: "a topic new to the suite and undescribed",
args: []string{"--topic", "config", "--prefix", "CONF", "--title", "Конфигурация"},
want: "--about is required",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, append([]string{"suite", "add"}, tc.args...)...)
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
checkClean(t, root)
}
func TestInitRefusesInsideASuite(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
code, out := run(t, ".", "", false, "suite", "init", "--path", root)
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
if !strings.Contains(out, "this is a suite already") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
func TestInitRefusesAnUnknownVocabulary(t *testing.T) {
root := filepath.Join(t.TempDir(), "suite")
code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "xx")
if code != cli.Usage {
t.Fatalf("expected a refusal, got %d: %s", code, out)
}
if !strings.Contains(out, "unknown to the tool") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
+153
View File
@@ -0,0 +1,153 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"slices"
"strings"
)
// Commands that change something run in two modes, and which one is meant is
// read off the command line: bare means interactive, any flag means automatic.
//
// The two modes address two different callers. A person types the command and
// is walked through the fields with a hint for each; an agent or a script
// passes every field at once and must never be blocked waiting on a terminal
// that is not there. Neither mode guesses: a field that is required and absent
// stops the run in both.
// Field is one thing a command asks for.
type Field struct {
// Flag is the name the field carries on the command line.
Flag string
// Ask is the question put to a person.
Ask string
// Hint is the one line explaining what belongs here and why.
Hint string
// Default is offered as the answer when the person just presses enter.
Default string
// Optional fields may stay empty.
Optional bool
// Options, when given, are the only answers accepted.
Options []string
// Check validates an answer; it is applied in both modes.
Check func(string) error
}
// errStop ends the dialogue on end of input.
var errStop = errors.New("input ended")
// dialogue asks the fields one by one, re-asking what did not pass validation.
type dialogue struct {
in *bufio.Reader
out io.Writer
}
func newDialogue(env Env) *dialogue {
return &dialogue{in: bufio.NewReader(env.In), out: env.Out}
}
// ask puts one question and returns the answer, looping until it validates.
func (d *dialogue) ask(f Field) (string, error) {
for {
if f.Hint != "" {
fmt.Fprintf(d.out, "\n%s\n", f.Hint)
}
prompt := f.Ask
switch {
case len(f.Options) > 0:
prompt += fmt.Sprintf(" (%s)", strings.Join(f.Options, " / "))
if f.Default != "" {
prompt += fmt.Sprintf(" [%s]", f.Default)
}
case f.Default != "":
prompt += fmt.Sprintf(" [%s]", f.Default)
case f.Optional:
prompt += " [may stay empty]"
}
fmt.Fprintf(d.out, "%s: ", prompt)
line, err := d.in.ReadString('\n')
answer := strings.TrimSpace(line)
if answer == "" && err != nil {
return "", errStop
}
if answer == "" {
answer = f.Default
}
if answer == "" && !f.Optional {
fmt.Fprintln(d.out, " the field is required")
continue
}
if err := validate(f, answer); err != nil {
fmt.Fprintf(d.out, " %s\n", err)
continue
}
return answer, nil
}
}
// confirm asks a yes-or-no question before something irreversible, defaulting
// to no: a retirement that went through because the reader pressed enter is the
// one mistake the model cannot undo.
func (d *dialogue) confirm(question string) (bool, error) {
fmt.Fprintf(d.out, "\n%s [y/N]: ", question)
line, err := d.in.ReadString('\n')
answer := strings.ToLower(strings.TrimSpace(line))
if answer == "" && err != nil {
return false, errStop
}
return answer == "y" || answer == "yes", nil
}
// validate applies a field's check to a value that is not empty.
func validate(f Field, value string) error {
if value == "" {
return nil
}
// Options are what the dialogue offers; a check, where there is one, is
// what decides. A field may well accept more than it suggests — naming a
// step by its category is the suggestion, naming it by the word of the
// suite is accepted all the same.
if f.Check != nil {
return f.Check(value)
}
if len(f.Options) > 0 && !slices.Contains(f.Options, value) {
return fmt.Errorf("one of: %s", strings.Join(f.Options, ", "))
}
return nil
}
// resolve settles the fields in automatic mode: what came on the command line
// is validated, what is required and missing is named. Every missing field is
// reported at once — being sent back one flag at a time is the worst way to
// learn what a command wants.
func resolve(fields []Field, given map[string]string) error {
var missing []string
var problems []string
for _, f := range fields {
value := given[f.Flag]
if value == "" {
value = f.Default
given[f.Flag] = value
}
if value == "" {
if !f.Optional {
missing = append(missing, "--"+f.Flag)
}
continue
}
if err := validate(f, value); err != nil {
problems = append(problems, fmt.Sprintf("--%s: %s", f.Flag, err))
}
}
if len(missing) > 0 {
problems = append(problems, "required and missing: "+strings.Join(missing, ", "))
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "\n"))
}
return nil
}
+228
View File
@@ -0,0 +1,228 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
// retirable builds a suite with two topics, three conventions and a couple of
// rules, which is the least that lets every case of retirement be told apart.
func retirable(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
run(t, root, "", false, "suite", "add", "--topic", "time", "--prefix", "GTIM", "--lang", "go", "--title", "Время на Go")
run(t, root, "", false, "suite", "add", "--topic", "logging", "--about", "логирование", "--prefix", "SLOG", "--title", "Логирование")
appendRules(t, root, "conventions/time.md", `
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
### TIME-2. Ширина строки фиксируется
**ДОЛЖЕН.** Внутри одной колонки длина строки времени одна.
**ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди
строк одинаковой длины.
`)
appendRules(t, root, "conventions/logging.md", `
### SLOG-1. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
`)
return root
}
func appendRules(t *testing.T, root, rel, rules string) {
t.Helper()
name := filepath.Join(root, filepath.FromSlash(rel))
body, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, append(body, []byte("\n## Правила\n"+rules)...), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRetireRuleLeavesAStub(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-2", "--reason", "ширина следует из TIME-1 и отдельного правила не требует",
"--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
if !strings.Contains(body, "### TIME-2. Ширина строки фиксируется") {
t.Errorf("the heading and the number did not survive:\n%s", body)
}
if !strings.Contains(body, "**СНЯТО 2026-07-27.** ширина следует из TIME-1") {
t.Errorf("the stub is not there or is worded wrong:\n%s", body)
}
if strings.Contains(body, "Лексикографическая сортировка") {
t.Errorf("the rationale of the retired rule survived:\n%s", body)
}
if strings.Contains(body, "**ДОЛЖЕН.** Внутри одной колонки") {
t.Errorf("the norm of the retired rule survived:\n%s", body)
}
// The numbering stays contiguous and the whole suite still checks clean —
// which is the point of a stub over a deletion.
checkClean(t, root)
}
func TestRetireRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "уже снято", "--date", "2026-07-27")
cases := []struct{ name, id, want string }{
{"a rule already retired", "TIME-2", "retired already"},
{"a number no rule carries", "TIME-9", "holds no rule numbered 9"},
{"a prefix the suite does not declare", "ZZZZ-1", "no live prefix ZZZZ"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, "suite", "retire", "--rule", tc.id, "--reason", "почему-то")
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
}
// A file takes its rules with it, and nothing stands in for a file the way a
// stub stands in for a rule. So a convention still pointed at cannot go.
func TestRetirePrefixRefusesWhileStillPointedAt(t *testing.T) {
root := retirable(t)
name := filepath.Join(root, "conventions", "logging.md")
body, _ := os.ReadFile(name)
if err := os.WriteFile(name, append(body,
[]byte("\nВремя в записи следует TIME-1.\n")...), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "тема закрыта")
if code == cli.OK {
t.Fatalf("a convention still pointed at was retired:\n%s", out)
}
for _, want := range []string{"still pointed at", "conventions/logging.md", "TIME-1"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
if _, err := os.Stat(filepath.Join(root, "conventions", "time.md")); err != nil {
t.Error("the file was removed despite the refusal")
}
}
func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--prefix", "SLOG", "--reason", "тема свёрнута", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a convention returned %d: %s", code, out)
}
if _, err := os.Stat(filepath.Join(root, "conventions", "logging.md")); !os.IsNotExist(err) {
t.Error("the file stayed behind")
}
toml := read(t, root, "suite.toml")
if strings.Contains(toml, `SLOG = "conventions/logging.md"`) {
t.Errorf("the prefix stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `SLOG = "2026-07-27, was conventions/logging.md: тема свёрнута"`) {
t.Errorf("the retired half does not carry the date, the path and the reason:\n%s", toml)
}
if !strings.Contains(out, "no layer left") {
t.Errorf("the report does not point out that the topic is now empty:\n%s", out)
}
}
// A topic lives as long as one layer of it does, so the name cannot go first.
func TestRetireTopicWaitsForItsLayers(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "не нужна")
if code == cli.OK {
t.Fatalf("a topic with layers was retired:\n%s", out)
}
for _, want := range []string{"still holds layers", "conventions/time.md", "conventions/lang/go/time.md"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
run(t, root, "", false, "suite", "retire", "--prefix", "GTIM", "--reason", "слой снят", "--date", "2026-07-27")
run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "слой снят", "--date", "2026-07-27")
code, out = run(t, root, "", false, "suite", "retire",
"--topic", "time", "--reason", "решение переехало в logging", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring an empty topic returned %d: %s", code, out)
}
toml := read(t, root, "suite.toml")
if strings.Contains(toml, `time = "время"`) {
t.Errorf("the topic stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `time = "2026-07-27: решение переехало в logging"`) {
t.Errorf("the retired half does not carry the date and the reason:\n%s", toml)
}
checkClean(t, root)
}
func TestRetireNeedsExactlyOneTarget(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-1", "--topic", "time", "--reason", "и то и другое")
if code == cli.OK {
t.Fatalf("two targets at once went through:\n%s", out)
}
if !strings.Contains(out, "exactly one") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// Retirement is irreversible, so the dialogue asks before writing and takes
// silence for no.
func TestRetireInteractiveConfirms(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "rule\nTIME-2\nбольше не нужно\n\n", true, "suite", "retire")
if code == cli.OK {
t.Fatalf("an unconfirmed retirement went through:\n%s", out)
}
if !strings.Contains(out, "nothing was written") {
t.Errorf("the report does not say that nothing happened:\n%s", out)
}
if strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("the file was written despite no confirmation")
}
code, out = run(t, root, "rule\nTIME-2\nбольше не нужно\ny\n", true, "suite", "retire")
if code != cli.OK {
t.Fatalf("a confirmed retirement returned %d: %s", code, out)
}
if !strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("a confirmed retirement wrote nothing")
}
}
+201
View File
@@ -0,0 +1,201 @@
package cli_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
func TestRuleTakesTheNextNumberAndKeepsTheOrderOfBlocks(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Точность носителя фиксируется",
"--modality", "requirement",
"--norm", "У колонки БД и у потока логов точность объявлена и не плавает.",
"--why", "Плавающая точность ломает сортировку выборочно и невоспроизводимо.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number is not the next after the highest:\n%s", out)
}
body := read(t, root, "conventions/time.md")
want := "### TIME-3. Точность носителя фиксируется\n\n**ДОЛЖЕН.** У колонки БД и у потока логов точность объявлена и не плавает.\n\n**ПОЧЕМУ.** Плавающая точность ломает сортировку выборочно и невоспроизводимо."
if !strings.Contains(body, want) {
t.Errorf("the rule is not laid out as the language fixes it:\n%s", body)
}
checkClean(t, root)
}
// A number is never reused, so retiring a rule does not free its number: the
// next one still comes after the highest.
func TestRuleNumbersPastARetiredOne(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "не нужно", "--date", "2026-07-27")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма.", "--why", "Причина.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number of a retired rule was reused:\n%s", out)
}
checkClean(t, root)
}
// Order in a file goes by reading rather than by number, so a rule elaborating
// another has to be placeable next to it.
func TestRulePlacesAfterTheOneItElaborates(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма третьего.", "--why", "Причина третьего.")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Уточнение первого", "--modality", "permission",
"--norm", "Норма четвёртого.", "--why", "Причина четвёртого.",
"--after", "TIME-1")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
order := []string{"### TIME-1.", "### TIME-4.", "### TIME-2.", "### TIME-3."}
at := 0
for _, id := range order {
i := strings.Index(body[at:], id)
if i < 0 {
t.Fatalf("%s is missing or out of place:\n%s", id, body)
}
at += i
}
checkClean(t, root)
}
// The step is named by its category rather than by a word of any one language,
// so that a caller need not know which language the suite is written in. The
// word itself is taken too, for whoever has it at hand.
func TestRuleTakesTheStepByCategoryOrByWord(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через категорию", "--modality", "not-recommended",
"--norm", "Норма.", "--why", "Причина.")
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через слово", "--modality", "ДОПУСКАЕТСЯ",
"--norm", "Норма.", "--why", "Причина.")
body := read(t, root, "conventions/logging.md")
for _, want := range []string{"**НЕ СЛЕДУЕТ.** Норма.", "**ДОПУСКАЕТСЯ.** Норма."} {
if !strings.Contains(body, want) {
t.Errorf("the step did not render as %q:\n%s", want, body)
}
}
checkClean(t, root)
}
func TestRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
cases := []struct {
name string
args []string
want string
}{{
name: "a prefix the suite does not declare",
args: []string{"--prefix", "ZZZZ", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П"},
want: "no live prefix ZZZZ",
}, {
name: "a step that is not on the scale",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "maybe", "--norm", "Н", "--why", "П"},
want: "one of: requirement, prohibition",
}, {
name: "placing after a rule of another file",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П", "--after", "SLOG-1"},
want: "belongs to another file",
}, {
name: "a rationale left out",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н"},
want: "--why",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, append([]string{"suite", "rule"}, tc.args...)...)
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
checkClean(t, root)
}
func TestListShowsTheSuiteBaseLayerFirst(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
for _, want := range []string{"time — время", "logging — логирование", "TIME", "GTIM", "lang=go", "base"} {
if !strings.Contains(out, want) {
t.Errorf("the listing lacks %q:\n%s", want, out)
}
}
if strings.Index(out, "conventions/time.md") > strings.Index(out, "conventions/lang/go/time.md") {
t.Errorf("a language layer came before the base one:\n%s", out)
}
}
// The selection is the read-only half of assembly: a layer travels when its
// axis keys agree with the component, and the base layer travels always.
func TestListSelectsWhatAComponentWouldTake(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "go")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if strings.Contains(out, "left out") {
t.Errorf("a component of the right language left a layer out:\n%s", out)
}
code, out = run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "python")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "conventions/lang/go/time.md") || !strings.Contains(out, "left out") {
t.Errorf("the go layer was not left out for a python component:\n%s", out)
}
if !strings.Contains(out, "conventions/time.md") {
t.Errorf("the base layer did not travel:\n%s", out)
}
}
func TestListShowsRetiredNames(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--retired")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "nothing has been retired yet") {
t.Errorf("an untouched suite reported retirements:\n%s", out)
}
run(t, root, "", false, "suite", "retire", "--prefix", "SLOG", "--reason", "свёрнута", "--date", "2026-07-27")
_, out = run(t, root, "", false, "suite", "list", "--retired")
for _, want := range []string{"SLOG", "2026-07-27", "свёрнута", "ever handed out again"} {
if !strings.Contains(out, want) {
t.Errorf("the listing of retired names lacks %q:\n%s", want, out)
}
}
}
+380
View File
@@ -0,0 +1,380 @@
package cli
import (
"flag"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
var (
nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
)
func runSuiteAdd(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite add", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
topic := fs.String("topic", "", "name of the topic the convention belongs to")
about := fs.String("about", "", "one line about the topic, for a new topic only")
prefix := fs.String("prefix", "", "prefix of the rules, four uppercase Latin letters")
title := fs.String("title", "", "title of the document")
intro := fs.String("intro", "", "introductory prose, one or two sentences")
langAxis := fs.String("lang", "", "language axis of the layer; empty means the base layer")
stackAxis := fs.String("stack", "", "stack axis of the layer; empty means the base layer")
file := fs.String("path", "", "path of the file from the root of the suite")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
given := map[string]string{
"topic": *topic, "about": *about, "prefix": *prefix,
"title": *title, "intro": *intro,
"lang": *langAxis, "stack": *stackAxis, "path": *file,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite add without arguments asks questions, and there is no terminal to ask on; pass --topic, --prefix and --title")
return Usage
}
if err := askAdd(env, s, given); err != nil {
return Usage
}
} else {
if err := resolve(addFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if given["path"] == "" {
given["path"] = defaultPath(given)
}
if err := checkTopicAbout(s, given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
}
return writeConvention(env, s, given)
}
// addFields describes what a convention needs to be added. The checks live here
// rather than at the point of writing so that both modes apply the same ones.
func addFields(s *suite.Suite) []Field {
return []Field{{
Flag: "topic",
Ask: "Topic",
Hint: "The focus the rules are about: time, config, db-schema. A topic is the unit of subscription — a consumer takes it whole. The name never changes and is never reused.",
Check: func(v string) error {
if !nameRe.MatchString(v) {
return fmt.Errorf("a topic name travels into the file system of a consumer, so it is written in Latin letters and digits")
}
if s.Manifest.TopicRetired(v) {
return fmt.Errorf("the topic %q is retired and cannot be handed out again", v)
}
return nil
},
}, {
Flag: "prefix",
Ask: "Prefix of the rules",
Hint: "Four uppercase Latin letters, unique across the suite, one per file. Pick a word that reads, not a formula: the prefix exists to be searched for. Rules will be numbered PREFIX-1, PREFIX-2.",
Check: func(v string) error {
if err := manifest.ValidPrefix(v); err != nil {
return err
}
if _, taken := s.Manifest.PathOf(v); taken {
return fmt.Errorf("the prefix %s is already taken in the suite", v)
}
if s.Manifest.PrefixRetired(v) {
return fmt.Errorf("the prefix %s is retired and is never reissued", v)
}
return nil
},
}, {
Flag: "title",
Ask: "Title of the document",
Hint: "The level-one heading, in the language of the suite.",
}, {
Flag: "lang",
Ask: "Language axis",
Hint: "The programming language this layer is about, if it is about one: go, python. Leave empty for the base layer, the one that reaches every copy.",
Optional: true,
Check: axisCheck,
}, {
Flag: "stack",
Ask: "Stack axis",
Hint: "The tool or framework this layer is about, if it is about one: htmx, ansible. Leave empty together with the language axis to get the base layer.",
Optional: true,
Check: axisCheck,
}, {
Flag: "intro",
Ask: "Introductory prose",
Hint: "One or two sentences on what the convention covers. Key words in capitals do not belong here: prose is never a norm.",
Optional: true,
}}
}
func axisCheck(v string) error {
if !nameRe.MatchString(v) {
return fmt.Errorf("an axis value becomes a directory name, so it is written in Latin letters and digits")
}
return nil
}
// askAdd walks the dialogue, asking for the topic description only when the
// topic is new and for the path once the axis is known.
func askAdd(env Env, s *suite.Suite, given map[string]string) error {
d := newDialogue(env)
fields := addFields(s)
for _, f := range fields {
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
if f.Flag != "topic" {
continue
}
if s.Manifest.TopicLive(answer) {
fmt.Fprintf(env.Out, " the topic is known, this will be another layer of it\n")
continue
}
about, err := d.ask(Field{
Flag: "about",
Ask: "One line about the topic",
Hint: "It goes into the manifest and builds the table of conventions in a consumer's README.",
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given["about"] = about
}
answer, err := d.ask(Field{
Flag: "path",
Ask: "Path of the file",
Hint: "Where the file lies in the suite. The directory tree documents the tie between layers for a human; what a layer actually is comes from the front matter.",
Default: defaultPath(given),
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given["path"] = answer
return nil
}
// checkTopicAbout guards the one field whose need depends on another: a new
// topic has to be described, a known one is described already.
func checkTopicAbout(s *suite.Suite, given map[string]string) error {
if s.Manifest.TopicLive(given["topic"]) {
return nil
}
if given["about"] == "" {
return fmt.Errorf("the topic %q is new to the suite, so --about is required: the line describes it in the manifest and in a consumer's README", given["topic"])
}
return nil
}
// defaultPath puts a file where the axis says it belongs. The path is
// documentation, not a declaration — but documentation that agrees with the
// declaration costs nothing to produce.
func defaultPath(given map[string]string) string {
topic := given["topic"]
switch {
case given["lang"] != "":
return path.Join("conventions/lang", given["lang"], topic+".md")
case given["stack"] != "":
return path.Join("conventions/stack", given["stack"], topic+".md")
}
return path.Join("conventions", topic+".md")
}
// writeConvention writes the file and splices the manifest. The file goes first
// and the manifest second, because a file the manifest does not declare is
// reported by the check, while a declared file that is missing is an error the
// author has to undo by hand.
func writeConvention(env Env, s *suite.Suite, given map[string]string) ExitCode {
if given["lang"] != "" && given["stack"] != "" {
// Both axes at once is a layer meaningful only when language and tool
// coincide. The model allows it; the default path does not express
// it, so the path has to be given explicitly.
if given["path"] == "" {
fmt.Fprintln(env.Err, "a layer on both axes needs --path: the tree cannot express two axes at once")
return Usage
}
}
rel := filepath.ToSlash(given["path"])
name := filepath.Join(s.Root, filepath.FromSlash(rel))
if _, err := os.Stat(name); err == nil {
fmt.Fprintf(env.Err, "%s already exists\n", rel)
return Usage
}
base := baseLayerOf(s, given["topic"])
if base == "" && given["lang"] == "" && given["stack"] == "" {
// The first layer of a topic and no axis: this is the base one.
} else if base == "" {
fmt.Fprintf(env.Out, "note: the topic has no base layer yet, so this one stands alone\n")
} else if given["lang"] == "" && given["stack"] == "" {
fmt.Fprintf(env.Err, "the topic %q already holds a base layer (%s): a second one would put two base texts in one assembled file\n", given["topic"], base)
return Usage
}
body := conventionSkeleton(s, given, base, rel)
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
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 {
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"])
if given["about"] != "" {
fmt.Fprintf(env.Out, ", topic %s", given["topic"])
}
fmt.Fprintf(env.Out, "\n\nwrite the first rule as ### %s-1, then run convy suite check\n", given["prefix"])
if !kebabRe.MatchString(given["topic"]) {
fmt.Fprintf(env.Out, "note: lower kebab-case is the recommended shape for a topic name\n")
}
return OK
}
// baseLayerOf returns the path of the base layer of a topic, if the suite holds
// one.
func baseLayerOf(s *suite.Suite, topic string) string {
for _, d := range s.Layers(topic) {
if !d.Front.Axis() {
return d.Path
}
}
return ""
}
// conventionSkeleton builds the file: front matter, title, prose and the
// language version line. The line is rendered out of the suite's vocabulary,
// which is the whole point of the vocabulary being a property of the language
// rather than of the code — what the tool writes, the tool also accepts.
func conventionSkeleton(s *suite.Suite, given map[string]string, base, rel string) string {
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "topic: %s\n", given["topic"])
fmt.Fprintf(&b, "prefix: %s\n", given["prefix"])
if given["lang"] != "" {
fmt.Fprintf(&b, "lang: %s\n", given["lang"])
}
if given["stack"] != "" {
fmt.Fprintf(&b, "stack: %s\n", given["stack"])
}
if base != "" {
fmt.Fprintf(&b, "extends: %s\n", extendsRef(s, base, rel))
}
b.WriteString("---\n\n")
fmt.Fprintf(&b, "# %s\n\n", given["title"])
if given["intro"] != "" {
fmt.Fprintf(&b, "%s\n\n", given["intro"])
}
b.WriteString(s.Vocab.VersionLine())
b.WriteString("\n")
return b.String()
}
// extendsRef writes the base layer the way the suite already writes it: from
// the directory the two files share, when that form names one file and no more.
//
// The short form is what a reader expects, but two layers of one topic usually
// carry the same file name, so a tail like "time.md" can fit both the base
// layer and the file being written. Where that happens the reference falls back
// to the path from the root of the suite, which fits exactly one file always.
func extendsRef(s *suite.Suite, base, rel string) string {
short := base
shared := path.Dir(rel)
for shared != "." && shared != "/" {
if rest, ok := strings.CutPrefix(base, path.Dir(shared)+"/"); ok {
short = rest
break
}
shared = path.Dir(shared)
}
if short == base || unambiguous(s, short, base, rel) {
return short
}
return base
}
// unambiguous reports whether a tail names the base layer and nothing else,
// counting the file about to be written as one of the suite's own.
func unambiguous(s *suite.Suite, short, base, rel string) bool {
paths := []string{rel}
for _, d := range s.Docs {
paths = append(paths, d.Path)
}
hits := 0
for _, p := range paths {
if p == short || strings.HasSuffix(p, "/"+short) {
hits++
}
}
return hits == 1 && (base == short || strings.HasSuffix(base, "/"+short))
}
// suiteRoot settles the directory a suite command works in.
func suiteRoot(env Env, given string) (string, ExitCode) {
if given != "" {
return given, OK
}
found, err := manifest.Find(env.Dir)
if err != nil {
fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name)
fmt.Fprintln(env.Err, "convy suite init starts one")
return "", Usage
}
return found, OK
}
+100
View File
@@ -0,0 +1,100 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
func runSuiteCheck(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite check", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory")
quiet := fs.Bool("quiet", false, "print findings only")
if err := fs.Parse(args); err != nil {
return Usage
}
dir := *root
if dir == "" {
found, err := manifest.Find(env.Dir)
if err != nil {
if errors.Is(err, manifest.ErrNotFound) {
fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name)
if _, err := os.Stat(filepath.Join(env.Dir, ".conventions.toml")); err == nil {
fmt.Fprintln(env.Err, "this is a project — checking what is here is called \"convy check\"")
}
return Usage
}
fmt.Fprintln(env.Err, err)
return Usage
}
dir = found
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
rep := check.Suite(s)
printReport(env.Out, rep, s, *quiet)
if rep.Errors() > 0 {
return Failed
}
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)
}
if quiet {
return
}
if len(findings) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "suite: %s, %s, language version %d (%s)\n",
plural(len(s.Docs), "file"),
plural(len(s.Manifest.LiveTopics()), "topic"),
s.Manifest.Language.Version, s.Manifest.Language.Lang)
switch {
case rep.Errors() > 0:
fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings())
case rep.Warnings() > 0:
fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
default:
fmt.Fprintln(w, "suite integrity holds")
}
}
// plural agrees a noun with a count: 1 file, 4 files.
func plural(n int, noun string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, noun)
}
return fmt.Sprintf("%d %ss", n, noun)
}
+145
View File
@@ -0,0 +1,145 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/lang"
"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.
//
// 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.
[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
# ─── 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.
[topics.live]
# 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.retired]
# ─── 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.
[prefixes.live]
# Prefixes of deleted and split files land here, likewise never to be reissued.
[prefixes.retired]
`
func runSuiteInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite init", flag.ContinueOnError)
fs.SetOutput(env.Err)
path := fs.String("path", "", "directory of the suite; it is created if absent")
code := fs.String("lang", "", "code of the natural language the suite is written in")
version := fs.Int("language-version", 1, "version of the conventions language")
if err := fs.Parse(args); err != nil {
return Usage
}
fields := []Field{{
Flag: "path",
Ask: "Directory of the suite",
Hint: "Where the suite will live. The directory is created if it is not there yet.",
Default: ".",
}, {
Flag: "lang",
Ask: "Natural language of the suite",
Hint: "The language the rules are written in. It settles the key words: ДОЛЖЕН and ПОЧЕМУ for ru, MUST and WHY for en.",
Default: manifest.DefaultLanguageCode,
Check: func(v string) error {
_, err := lang.Lookup(*version, v)
return err
},
}}
given := map[string]string{"path": *path, "lang": *code}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite init without arguments asks questions, and there is no terminal to ask on; pass --path and --lang")
return Usage
}
if err := askAll(env, fields, given); err != nil {
return Usage
}
} else if err := resolve(fields, given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
root := given["path"]
if _, err := os.Stat(filepath.Join(root, manifest.Name)); err == nil {
fmt.Fprintf(env.Err, "%s already holds %s: this is a suite already\n", root, manifest.Name)
return Usage
}
if err := os.MkdirAll(filepath.Join(root, "conventions"), 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
body := fmt.Sprintf(manifestSkeleton, *version, given["lang"])
name := filepath.Join(root, manifest.Name)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
fmt.Fprintf(env.Out, "\ncreated %s\ncreated %s\n", name, filepath.Join(root, "conventions"))
fmt.Fprintf(env.Out, "\nthe suite speaks %s, conventions language version %d\n", given["lang"], *version)
fmt.Fprint(env.Out, `
next:
write the two documents about the language and name them in [language]
convy suite add add the first convention
convy suite check verify the suite holds together
`)
return OK
}
// askAll walks the fields in interactive mode, keeping answers already given on
// the command line.
func askAll(env Env, fields []Field, given map[string]string) error {
d := newDialogue(env)
for _, f := range fields {
if given[f.Flag] != "" {
continue
}
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"flag"
"fmt"
"io"
"sort"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// suite list answers two questions. Bare, it says what the suite holds: topics,
// their layers, the prefixes taken. Given an axis, it says what a component
// would take — which is the read-only half of assembly, and the reason to build
// it before anything starts writing copies into other repositories.
func runSuiteList(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite list", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
topic := fs.String("topic", "", "show one topic only")
langAxis := fs.String("lang", "", "language of the component, to show what it would take")
stackAxis := fs.String("stack", "", "stack of the component, to show what it would take")
retired := fs.Bool("retired", false, "show the retired names instead of the live ones")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
if *retired {
listRetired(env.Out, s)
return OK
}
topics := s.Manifest.LiveTopics()
if *topic != "" {
if !s.Manifest.TopicLive(*topic) {
fmt.Fprintf(env.Err, "the suite declares no live topic %q\n", *topic)
return Usage
}
topics = []string{*topic}
}
// The path column is sized to the suite rather than guessed: a name that
// overruns a fixed width breaks every row below it.
width := 0
for _, d := range s.Docs {
width = max(width, len(d.Path))
}
selecting := *langAxis != "" || *stackAxis != ""
component := suite.Component{Lang: *langAxis, Stack: *stackAxis}
for i, name := range topics {
if i > 0 {
fmt.Fprintln(env.Out)
}
listTopic(env.Out, s, name, component, selecting, width)
}
if !selecting {
fmt.Fprintf(env.Out, "\n%s, %s, language version %d (%s)\n",
plural(len(topics), "topic"), plural(len(s.Docs), "file"),
s.Manifest.Language.Version, s.Manifest.Language.Lang)
}
return OK
}
func listTopic(w io.Writer, s *suite.Suite, name string, c suite.Component, selecting bool, width int) {
fmt.Fprintf(w, "%s — %s\n", name, s.Manifest.Topics.Live[name])
if !selecting {
for _, d := range s.Layers(name) {
fmt.Fprintln(w, " "+layerLine(s, d, width))
}
return
}
taken, left := s.Assemble(name, c)
if len(taken) == 0 {
fmt.Fprintln(w, " nothing: the topic has no layer this component takes")
}
for _, d := range taken {
fmt.Fprintln(w, " "+layerLine(s, d, width))
}
for _, d := range left {
fmt.Fprintln(w, " · "+layerLine(s, d, width)+" left out")
}
}
// layerLine describes one layer: its prefix, where it lies, what axis it is on
// and how much of it there is.
func layerLine(s *suite.Suite, d *doc.Document, width int) string {
rules, retired := 0, 0
for _, r := range d.Rules {
rules++
if _, ok := r.Block(lang.Retired); ok {
retired++
}
}
count := plural(rules, "rule")
if retired > 0 {
count += fmt.Sprintf(", %d retired", retired)
}
return fmt.Sprintf("%-6s %-*s %-22s %s", s.Prefix(d), width, d.Path, suite.Axis(d), count)
}
func listRetired(w io.Writer, s *suite.Suite) {
sections := []struct {
title string
entries map[string]string
}{
{"topics", s.Manifest.Topics.Retired},
{"prefixes", s.Manifest.Prefixes.Retired},
}
empty := true
for _, section := range sections {
if len(section.entries) == 0 {
continue
}
empty = false
fmt.Fprintf(w, "%s\n", section.title)
for _, key := range sortedKeys(section.entries) {
fmt.Fprintf(w, " %-16s %s\n", key, section.entries[key])
}
}
if empty {
fmt.Fprintln(w, "nothing has been retired yet")
return
}
fmt.Fprintln(w, "\nnone of these names is ever handed out again")
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+396
View File
@@ -0,0 +1,396 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Retirement is the one operation in the model whose mistakes cannot be undone.
// A text rewritten badly gets rewritten again; a prefix handed out twice is a
// reference from a foreign repository that now points at a different statement,
// and that is discovered by its content rather than by any check.
//
// So the command does the bookkeeping the discipline otherwise leans on
// attention for: it writes the date, it words the stub out of the suite's own
// vocabulary, it moves the name into the retired half instead of deleting it,
// and it refuses whatever would leave a reference pointing at nothing.
var ruleIDRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)$`)
func runSuiteRetire(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite retire", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
rule := fs.String("rule", "", "identifier of a rule to retire, PREFIX-N")
prefix := fs.String("prefix", "", "prefix of a convention to retire together with its file")
topic := fs.String("topic", "", "name of a topic to retire")
reason := fs.String("reason", "", "why it is being retired")
date := fs.String("date", "", "date of retirement; today by default")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
when := *date
if when == "" {
when = time.Now().Format("2006-01-02")
}
given := map[string]string{"rule": *rule, "prefix": *prefix, "topic": *topic, "reason": *reason}
talk := newDialogue(env)
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite retire without arguments asks questions, and there is no terminal to ask on; pass one of --rule, --prefix, --topic together with --reason")
return Usage
}
if err := askRetire(env, talk, s, given); err != nil {
return Usage
}
} else if err := resolve(retireFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
targets := 0
for _, key := range []string{"rule", "prefix", "topic"} {
if given[key] != "" {
targets++
}
}
if targets != 1 {
fmt.Fprintln(env.Err, "name exactly one of --rule, --prefix, --topic: the three retire different things and undo differently")
return Usage
}
// The dialogue is built once and carried through: a second one over the
// same input would find it drained, because the first reads ahead.
var d *dialogue
if env.Interactive {
d = talk
}
switch {
case given["rule"] != "":
return retireRule(env, d, s, given, when)
case given["prefix"] != "":
return retirePrefix(env, d, s, given, when)
default:
return retireTopic(env, d, s, given, when)
}
}
func retireFields(s *suite.Suite) []Field {
return []Field{{
Flag: "rule",
Ask: "Rule",
Optional: true,
Check: func(v string) error {
if !ruleIDRe.MatchString(v) {
return fmt.Errorf("a rule is named by its identifier, PREFIX-N")
}
return nil
},
}, {
Flag: "prefix",
Ask: "Prefix",
Optional: true,
Check: func(v string) error {
if _, ok := s.Manifest.PathOf(v); !ok {
return fmt.Errorf("the suite declares no live prefix %s", v)
}
return nil
},
}, {
Flag: "topic",
Ask: "Topic",
Optional: true,
Check: func(v string) error {
if !s.Manifest.TopicLive(v) {
return fmt.Errorf("the suite declares no live topic %q", v)
}
return nil
},
}, {
Flag: "reason",
Ask: "Reason",
Hint: "Why it is going. The reason outlives the thing itself — it is what a reader finds in place of what they were looking for.",
}}
}
// askRetire walks the dialogue: what kind of thing, which one, and why.
func askRetire(env Env, d *dialogue, s *suite.Suite, given map[string]string) error {
fields := retireFields(s)
kind, err := d.ask(Field{
Ask: "What is being retired",
Hint: "A rule keeps its number and turns into a stub; a convention takes its file with it; a topic goes once no layer of it is left.",
Options: []string{"rule", "convention", "topic"},
Default: "rule",
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
flagOf := map[string]string{"rule": "rule", "convention": "prefix", "topic": "topic"}[kind]
for _, f := range fields {
if f.Flag != flagOf && f.Flag != "reason" {
continue
}
f.Optional = false
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}
// retireRule replaces the norm and the rationale with a stub, keeping the
// heading and the number. The number stays taken forever, so a reference from a
// foreign repository lands on the explanation instead of on nothing.
func retireRule(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
m := ruleIDRe.FindStringSubmatch(given["rule"])
num, _ := strconv.Atoi(m[2])
target, ok := s.ByPrefix[m[1]]
if !ok {
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", m[1])
return Usage
}
var found *doc.Rule
for i := range target.Rules {
if target.Rules[i].Num == num {
found = &target.Rules[i]
}
}
if found == nil {
fmt.Fprintf(env.Err, "%s holds no rule numbered %d\n", target.Path, num)
return Usage
}
if _, already := found.Block(lang.Retired); already {
fmt.Fprintf(env.Err, "%s is retired already\n", found.ID())
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"%s in %s loses its norm and its rationale, keeping the number. Go on?", found.ID(), target.Path))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
name := filepath.Join(s.Root, filepath.FromSlash(target.Path))
body, err := os.ReadFile(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
lines := strings.Split(string(body), "\n")
stub := fmt.Sprintf("**%s %s.** %s", s.Vocab.MarkWord(lang.Retired), when, given["reason"])
replacement := append([]string{""}, wrap(stub, 78)...)
replacement = append(replacement, "")
out := make([]string, 0, len(lines))
out = append(out, lines[:found.Line]...)
out = append(out, replacement...)
if found.End < len(lines) {
out = append(out, lines[found.End:]...)
}
if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s is retired in %s\n", found.ID(), target.Path)
fmt.Fprintf(env.Out, "the number stays taken, and a reference to it now lands on the reason\n")
return OK
}
// retirePrefix retires a convention together with its file. The rules of that
// file cease to exist, so the command refuses while anything still points at
// them: a stub can stand in for a rule, but nothing stands in for a file.
func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
prefix := given["prefix"]
target, ok := s.ByPrefix[prefix]
if !ok {
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", prefix)
return Usage
}
if pointing := referencesTo(s, prefix, target); len(pointing) > 0 {
fmt.Fprintf(env.Err, "%s is still pointed at, so retiring it would leave references resolving to nothing:\n", prefix)
for _, where := range pointing {
fmt.Fprintf(env.Err, " %s\n", where)
}
fmt.Fprintln(env.Err, "move or retire those rules first")
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"%s goes, and %s is deleted. The prefix is never reissued. Go on?", target.Path, target.Path))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
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 {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.Remove(filepath.Join(s.Root, filepath.FromSlash(target.Path))); err != nil {
fmt.Fprintf(env.Err, "the manifest was updated, but the file was not removed: %s\n", err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s is retired and %s is gone\n", prefix, target.Path)
if left := s.Layers(target.Front.Topic); len(left) == 1 {
fmt.Fprintf(env.Out, "the topic %q has no layer left; retire the topic too if it is done\n", target.Front.Topic)
}
return OK
}
// retireTopic sends a topic name to the retired half. A topic lives as long as
// one layer of it does, so the command refuses while any layer is left.
func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
topic := given["topic"]
if layers := s.Layers(topic); len(layers) > 0 {
fmt.Fprintf(env.Err, "the topic %q still holds layers, and a topic lives as long as one of them does:\n", topic)
for _, d := range layers {
fmt.Fprintf(env.Err, " %s\n", d.Path)
}
fmt.Fprintln(env.Err, "retire those conventions first")
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"the name %q goes to the retired half and is never handed to another topic. Go on?", topic))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
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 {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\nthe topic %q is retired\n", topic)
return OK
}
// referencesTo finds where the rules of a prefix are pointed at from outside
// the file that owns them.
func referencesTo(s *suite.Suite, prefix string, own *doc.Document) []string {
var out []string
for _, d := range s.Docs {
if d == own {
continue
}
for _, ref := range refsOf(d, prefix) {
out = append(out, fmt.Sprintf("%s:%d %s", d.Path, ref.line, ref.text))
}
}
return out
}
type refAt struct {
line int
text string
}
var anyRefRe = regexp.MustCompile(`\b[A-Z]{4}-\d+(?:\.\d+)?`)
func refsOf(d *doc.Document, prefix string) []refAt {
var out []refAt
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, text := range anyRefRe.FindAllString(doc.StripInline(d.Line(n)), -1) {
if strings.HasPrefix(text, prefix+"-") {
out = append(out, refAt{line: n, text: text})
}
}
}
return out
}
// wrap breaks a paragraph at a width, the way the text around it is written by
// hand. A stub that runs off in one long line reads as machine-made and gets
// reflowed by the next person to touch the file.
func wrap(text string, width int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{""}
}
lines := []string{words[0]}
for _, w := range words[1:] {
last := len(lines) - 1
if len([]rune(lines[last]))+1+len([]rune(w)) <= width {
lines[last] += " " + w
continue
}
lines = append(lines, w)
}
return lines
}
+224
View File
@@ -0,0 +1,224 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Adding a rule is where the tool stops merely judging the form and starts
// holding it. Everything the check verifies afterwards is known here already:
// the next free number, the prefix of the file, the words of the modality and
// the marks, the order of the blocks. Leaving the author to reproduce all of
// that by hand and then reporting what they got wrong is the worse half of the
// deal.
func runSuiteRule(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite rule", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
prefix := fs.String("prefix", "", "prefix of the convention the rule joins")
title := fs.String("title", "", "heading of the rule, what it is about")
modality := fs.String("modality", "", "step of the scale: "+strings.Join(lang.LevelNames(), ", "))
norm := fs.String("norm", "", "the norm itself, one statement")
why := fs.String("why", "", "what breaks if it is done otherwise")
examples := fs.String("examples", "", "illustration of the norm; optional")
after := fs.String("after", "", "identifier of the rule to place this one after; the last by default")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
given := map[string]string{
"prefix": *prefix, "title": *title, "modality": *modality,
"norm": *norm, "why": *why, "examples": *examples, "after": *after,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite rule without arguments asks questions, and there is no terminal to ask on; pass --prefix, --title, --modality, --norm and --why")
return Usage
}
if err := askAll(env, ruleFields(s), given); err != nil {
return Usage
}
} else if err := resolve(ruleFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
return writeRule(env, s, given)
}
func ruleFields(s *suite.Suite) []Field {
return []Field{{
Flag: "prefix",
Ask: "Prefix of the convention",
Hint: "Which file the rule joins. The number is taken from that file: the next one after its highest, never a number that was used before.",
Check: func(v string) error {
if _, ok := s.ByPrefix[v]; !ok {
return fmt.Errorf("the suite declares no live prefix %s", v)
}
return nil
},
}, {
Flag: "title",
Ask: "Heading of the rule",
Hint: "What the rule is about, in one line. It is read on its own in a list, so it says the substance rather than the topic.",
}, {
Flag: "modality",
Ask: "Step of the scale",
Hint: "How binding it is. The highest step asks two things at once: a named harm from breaking it, and a verdict two reviewers reach alike. Neither one — the rule belongs a step lower.",
Options: lang.LevelNames(),
Default: "recommendation",
Check: func(v string) error {
if _, ok := lang.LevelByName(v); ok {
return nil
}
if _, ok := s.Vocab.Modal(v); ok {
return nil
}
return fmt.Errorf("one of: %s — or the word the suite uses for the step",
strings.Join(lang.LevelNames(), ", "))
},
}, {
Flag: "norm",
Ask: "The norm",
Hint: "One statement of what is required. If it does not fit in one, these are two rules: half a compound norm cannot be addressed on its own.",
}, {
Flag: "why",
Ask: "The rationale",
Hint: "What breaks if it is done otherwise — not the norm said again. A cause that cannot be put into words means this is a habit rather than a rule.",
}, {
Flag: "examples",
Ask: "Examples",
Hint: "Code showing the norm at work, usually bad against good. Worth it where showing is cheaper than saying; a rule about choosing a boundary has nothing to illustrate.",
Optional: true,
}, {
Flag: "after",
Ask: "Place after",
Hint: "Identifier of the rule this one follows. Order in a file goes by reading rather than by number, so a rule elaborating another belongs next to it. Empty puts it last.",
Optional: true,
Check: func(v string) error {
if !ruleIDRe.MatchString(v) {
return fmt.Errorf("a rule is named by its identifier, PREFIX-N")
}
return nil
},
}}
}
func writeRule(env Env, s *suite.Suite, given map[string]string) ExitCode {
prefix := given["prefix"]
target := s.ByPrefix[prefix]
level, ok := lang.LevelByName(given["modality"])
if !ok {
if l, isWord := s.Vocab.Modal(given["modality"]); isWord {
level = l
} else {
fmt.Fprintf(env.Err, "unknown step %q; the steps are: %s\n",
given["modality"], strings.Join(lang.LevelNames(), ", "))
return Usage
}
}
own := ownRules(target, prefix)
if len(own) == 0 {
fmt.Fprintf(env.Err, "%s holds no rule yet, and a number is taken from the highest one; write the first rule as ### %s-1 by hand\n", target.Path, prefix)
return Usage
}
num := 0
for _, r := range own {
num = max(num, r.Num)
}
num++
// A number is never reused, so a rule retired long ago still owns its own.
// Taking the next after the highest is the only choice that cannot collide
// with a reference living in a foreign repository.
place := own[len(own)-1]
if given["after"] != "" {
m := ruleIDRe.FindStringSubmatch(given["after"])
if m[1] != prefix {
fmt.Fprintf(env.Err, "--after names %s, which belongs to another file than %s\n", given["after"], prefix)
return Usage
}
found := false
for _, r := range own {
if strconv.Itoa(r.Num) == m[2] {
place, found = r, true
}
}
if !found {
fmt.Fprintf(env.Err, "%s holds no rule %s\n", target.Path, given["after"])
return Usage
}
}
name := filepath.Join(s.Root, filepath.FromSlash(target.Path))
body, err := os.ReadFile(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
lines := strings.Split(string(body), "\n")
at := place.End
for at > place.Line && strings.TrimSpace(lines[at-1]) == "" {
at--
}
block := renderRule(s, prefix, num, level, given)
out := make([]string, 0, len(lines)+len(block))
out = append(out, lines[:at]...)
out = append(out, block...)
out = append(out, lines[at:]...)
if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s-%d added to %s after %s\n", prefix, num, target.Path, place.ID())
return OK
}
// renderRule lays out the blocks in the order the language fixes: the norm,
// then the reason, then the illustration.
func renderRule(s *suite.Suite, prefix string, num int, level lang.Level, given map[string]string) []string {
out := []string{"", fmt.Sprintf("### %s-%d. %s", prefix, num, given["title"]), ""}
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.Word(level), given["norm"]), 78)...)
out = append(out, "")
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.MarkWord(lang.Rationale), given["why"]), 78)...)
if given["examples"] != "" {
out = append(out, "")
out = append(out, wrap(fmt.Sprintf("**%s.** %s", s.Vocab.MarkWord(lang.Examples), given["examples"]), 78)...)
}
return out
}
// ownRules picks the rules a file numbers itself, in the order they stand.
func ownRules(d *doc.Document, prefix string) []doc.Rule {
var out []doc.Rule
for _, r := range d.Rules {
if r.Prefix == prefix {
out = append(out, r)
}
}
return out
}
+380
View File
@@ -0,0 +1,380 @@
// Package doc parses a file written in the conventions language: its front
// matter, its rules and their blocks.
//
// The parsing model is taken straight from the language. A rule is a heading of
// the form `### <PREFIX>-<number>. <title>`; the area of a rule runs from that
// heading to the next heading of any level. Inside the area text belongs to the
// last block opened: a mark opens a block, and the block lasts until the next
// mark or until the end of the area. Prose is what lies outside rule areas.
//
// The boundary is counted by the markup rather than by a judgement about where
// a rule ended: that is exactly what makes the "no modal words outside rules"
// check implementable at all.
package doc
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/lang"
)
// Heading is a heading of any level.
type Heading struct {
Level int
Text string
Line int
}
// BlockKind tells a norm block from a marked one.
type BlockKind int
const (
// Norm is a block of the norm, opened by a modal word.
Norm BlockKind = iota + 1
// Marked is a block under a mark: rationale, examples, retired,
// mechanized.
Marked
)
// Block is a part of a rule opened by a vocabulary word at the start of a
// paragraph.
type Block struct {
Kind BlockKind
Word string
Level lang.Level
Mark lang.Mark
// Start is the line the opening mark stands on.
Start int
// End is the last line of the block: a block lasts until the next mark
// or until the end of the rule area.
End int
// Rest is the text of the paragraph after the mark.
Rest string
}
// Rule is a rule: a heading carrying an identifier, plus its area.
type Rule struct {
Prefix string
Num int
Title string
// Line is the line of the heading.
Line int
// HeadingLevel is the level of the heading; the canonical form is three.
HeadingLevel int
// Malformed is set when a heading was recognized as a rule but is not
// written in the form `### <PREFIX>-<number>. <title>`.
Malformed string
// Start and End bound the rule area: from the line after the heading to
// the line before the next heading, inclusive.
Start, End int
Blocks []Block
}
// ID returns the identifier of the rule.
func (r Rule) ID() string {
return fmt.Sprintf("%s-%d", r.Prefix, r.Num)
}
// Block finds the first block under the given mark.
func (r Rule) Block(m lang.Mark) (Block, bool) {
for _, b := range r.Blocks {
if b.Kind == Marked && b.Mark == m {
return b, true
}
}
return Block{}, false
}
// Norms lists the norm blocks. There must be exactly zero of them (for a
// retired rule) or one: a norm is a single statement, and two norms under one
// number leave no way to address either on its own.
func (r Rule) Norms() []Block {
var out []Block
for _, b := range r.Blocks {
if b.Kind == Norm {
out = append(out, b)
}
}
return out
}
// Paragraph is a paragraph: the lines between blank ones.
type Paragraph struct {
Start, End int
Lines []string
}
// Text joins the paragraph into a single string.
func (p Paragraph) Text() string {
return strings.Join(p.Lines, " ")
}
// Document is a parsed file.
type Document struct {
// Path is the path from the root of the suite, in slash form.
Path string
Front Front
lines []string
fence []bool
// Body is the first line of the body, past the front matter.
Body int
Headings []Heading
Rules []Rule
}
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
// ruleHeadRe catches a heading that starts with a rule identifier —
// including one written out of form: otherwise a typo in a heading would
// turn a rule into prose and vanish from the numbering unnoticed.
ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`)
fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})")
)
// Load reads and parses a file. path is the path from the root of the suite,
// name the path in the file system.
func Load(path, name string) (*Document, error) {
data, err := os.ReadFile(name)
if err != nil {
return nil, err
}
return Parse(path, string(data))
}
// Parse parses the contents of a file.
func Parse(path, content string) (*Document, error) {
d := &Document{Path: path}
d.lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
front, body, err := parseFront(d.lines)
d.Front = front
d.Body = body
if err != nil {
return d, fmt.Errorf("%s: front matter: %w", path, err)
}
d.markFences()
d.collectHeadings()
d.collectRules()
return d, nil
}
// markFences marks the lines inside fenced code blocks. Everything checked by
// parsing text skips them: a SQL sample with an uppercase WHEN does not make
// the suite bilingual, and a `### XKEY-5` inside a documentation sample is not
// a rule.
func (d *Document) markFences() {
d.fence = make([]bool, len(d.lines))
open := ""
for i, line := range d.lines {
m := fenceRe.FindStringSubmatch(line)
if open == "" {
if m != nil {
open = m[1]
d.fence[i] = true
}
continue
}
d.fence[i] = true
if m != nil && len(m[1]) >= len(open) && m[1][0] == open[0] {
open = ""
}
}
}
func (d *Document) collectHeadings() {
for i, line := range d.lines {
num := i + 1
if num < d.Body || d.fence[i] {
continue
}
m := headingRe.FindStringSubmatch(line)
if m == nil {
continue
}
d.Headings = append(d.Headings, Heading{
Level: len(m[1]),
Text: strings.TrimSpace(m[2]),
Line: num,
})
}
}
func (d *Document) collectRules() {
for i, h := range d.Headings {
m := ruleHeadRe.FindStringSubmatch(h.Text)
if m == nil {
continue
}
num, err := strconv.Atoi(m[2])
if err != nil {
continue
}
rule := Rule{
Prefix: m[1],
Num: num,
Line: h.Line,
HeadingLevel: h.Level,
Start: h.Line + 1,
End: d.Len(),
}
if i+1 < len(d.Headings) {
rule.End = d.Headings[i+1].Line - 1
}
tail := m[3]
switch {
case strings.HasPrefix(tail, ". "):
rule.Title = strings.TrimSpace(tail[2:])
case tail == "":
rule.Malformed = "the rule heading has no title"
case strings.HasPrefix(tail, "."):
rule.Title = strings.TrimSpace(tail[1:])
default:
rule.Malformed = "no period after the identifier in the heading"
rule.Title = strings.TrimSpace(tail)
}
d.Rules = append(d.Rules, rule)
}
}
// Blocks marks up the rule areas using the suite's vocabulary. The markup is
// deferred until the manifest is loaded: before that it is unknown which
// vocabulary the suite is written in.
func (d *Document) Blocks(v lang.Vocabulary) {
for i := range d.Rules {
r := &d.Rules[i]
r.Blocks = nil
for _, p := range d.Paragraphs(r.Start, r.End) {
word, rest, ok := boldLead(p.Lines[0])
if !ok {
continue
}
w, ok := v.Lead(word)
if !ok {
continue
}
b := Block{Word: w, Start: p.Start, End: r.End, Rest: strings.TrimSpace(rest)}
if level, ok := v.Modal(w); ok {
b.Kind, b.Level = Norm, level
} else {
mark, _ := v.Mark(w)
b.Kind, b.Mark = Marked, mark
}
if n := len(r.Blocks); n > 0 {
r.Blocks[n-1].End = p.Start - 1
}
r.Blocks = append(r.Blocks, b)
}
}
}
// boldLead extracts the contents of the first bold span if the paragraph opens
// with one. A mark stands first in its paragraph, in bold and with a period —
// that is exactly what tells it from a mention of a step mid-sentence.
func boldLead(line string) (bold, rest string, ok bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "**") {
return "", "", false
}
end := strings.Index(line[2:], "**")
if end < 0 {
return "", "", false
}
return line[2 : 2+end], line[2+end+2:], true
}
// Paragraphs cuts a range of lines into paragraphs. Bounds are inclusive and
// numbering starts at one. Lines inside fenced blocks do not enter paragraphs:
// code is an illustration, not the text of a rule.
func (d *Document) Paragraphs(from, to int) []Paragraph {
var out []Paragraph
var cur *Paragraph
for n := max(from, 1); n <= min(to, d.Len()); n++ {
line := d.lines[n-1]
if d.fence[n-1] || strings.TrimSpace(line) == "" {
cur = nil
continue
}
if cur == nil {
out = append(out, Paragraph{Start: n, End: n})
cur = &out[len(out)-1]
}
cur.Lines = append(cur.Lines, line)
cur.End = n
}
return out
}
// Preamble returns the bounds of the introductory prose: from the body to the
// first rule.
func (d *Document) Preamble() (from, to int) {
if len(d.Rules) == 0 {
return d.Body, d.Len()
}
return d.Body, d.Rules[0].Line - 1
}
// InRule reports whether a line lies inside the area of some rule.
func (d *Document) InRule(n int) bool {
for _, r := range d.Rules {
if n >= r.Line && n <= r.End {
return true
}
}
return false
}
// Line returns line number n.
func (d *Document) Line(n int) string {
if n < 1 || n > d.Len() {
return ""
}
return d.lines[n-1]
}
// Fenced reports whether a line lies inside a fenced code block.
func (d *Document) Fenced(n int) bool {
return n >= 1 && n <= d.Len() && d.fence[n-1]
}
// Prose walks the lines of the body that did not land in fenced blocks and
// hands them over with the contents of inline code cut out. Inside backticks an
// identifier stands as a sample of the notation rather than as a reference —
// and it is the markup that tells the two apart.
func (d *Document) Prose(yield func(n int, text string) bool) {
for n := d.Body; n <= d.Len(); n++ {
if d.fence[n-1] {
continue
}
if !yield(n, StripInline(d.lines[n-1])) {
return
}
}
}
// StripInline cuts out the contents of inline code, keeping the delimiters.
func StripInline(line string) string {
var b strings.Builder
inCode := false
for _, r := range line {
if r == '`' {
inCode = !inCode
b.WriteRune(' ')
continue
}
if inCode {
b.WriteRune(' ')
continue
}
b.WriteRune(r)
}
return b.String()
}
// Len returns the number of lines in the file.
func (d *Document) Len() int { return len(d.lines) }
+83
View File
@@ -0,0 +1,83 @@
package doc
import (
"fmt"
"strings"
)
// Front is the front matter of a file. There are few keys and all of them are
// flat, so the parsing is done here: pulling in YAML for four `key: value`
// lines earns nothing.
//
// The axis of a layer is declared here by the lang and stack keys rather than
// derived from the path (META-38); the absence of both means the base layer of
// the topic.
type Front struct {
Topic string
Prefix string
Lang string
Stack string
Extends string
// At is the line a key was declared on, so a finding can point at the
// declaration rather than at the top of the file.
At map[string]int
// Unknown lists keys the model does not know.
Unknown []string
// End is the line of the closing delimiter. The body of the file starts
// on the next one.
End int
// Present says whether there was any front matter at all.
Present bool
}
// Axis reports whether the layer declares an axis. A layer without one is the
// base layer.
func (f Front) Axis() bool {
return f.Lang != "" || f.Stack != ""
}
// parseFront parses the front matter out of the file's lines. It returns the
// front matter and the number of the first line of the body.
func parseFront(lines []string) (Front, int, error) {
front := Front{At: make(map[string]int)}
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return front, 1, nil
}
front.Present = true
for i := 1; i < len(lines); i++ {
line := lines[i]
num := i + 1
if strings.TrimSpace(line) == "---" {
front.End = num
return front, num + 1, nil
}
if strings.TrimSpace(line) == "" {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
return front, num, fmt.Errorf("front matter line %d is not of the form \"key: value\"", num)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
front.At[key] = num
switch key {
case "topic":
front.Topic = value
case "prefix":
front.Prefix = value
case "lang":
front.Lang = value
case "stack":
front.Stack = value
case "extends":
front.Extends = value
default:
front.Unknown = append(front.Unknown, key)
}
}
return front, len(lines) + 1, fmt.Errorf("front matter is not closed by a --- delimiter")
}
+362
View File
@@ -0,0 +1,362 @@
// Package lang holds the vocabularies of the conventions language: the words
// that spell out a rule's modality, the marks of its blocks, and the
// connectives of a scenario block.
//
// A vocabulary belongs to a language version and to the suite's natural
// language, not to the suite itself: version 1 in Russian names the same words
// in every repository, and repeating that list in every manifest buys nothing.
// While the language specification lives together with the canon, the
// vocabularies live here; once it moves to its own repository with vocabulary
// files of its own, those become the source, and the Vocabulary type together
// with every check built on it stays as it is.
package lang
import (
"fmt"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// Level is a step on the scale of obligation. There are five steps in the four
// categories of ISO/IEC Directives, Part 2; which words name them is a
// parameter of the natural language, while the scale is one for all
// vocabularies.
type Level int
const (
Requirement Level = iota + 1
Prohibition
Recommendation
RecommendationAgainst
Permission
)
// String names the step for diagnostics — the role, not the vocabulary word.
func (l Level) String() string {
switch l {
case Requirement:
return "requirement"
case Prohibition:
return "prohibition"
case Recommendation:
return "recommendation"
case RecommendationAgainst:
return "recommendation against"
case Permission:
return "permission"
}
return "unknown level"
}
// levelNames are the language-neutral handles of the steps: the categories of
// the standard rather than the words of any one natural language. A caller
// naming a step says "requirement" and gets ДОЛЖЕН or MUST depending on the
// suite — which is what keeps an agent out of the business of knowing Russian.
var levelNames = []struct {
name string
level Level
}{
{"requirement", Requirement},
{"prohibition", Prohibition},
{"recommendation", Recommendation},
{"not-recommended", RecommendationAgainst},
{"permission", Permission},
}
// LevelByName resolves the handle of a step.
func LevelByName(name string) (Level, bool) {
for _, n := range levelNames {
if n.name == name {
return n.level, true
}
}
return 0, false
}
// LevelNames lists the handles in the order of the scale.
func LevelNames() []string {
out := make([]string, len(levelNames))
for i, n := range levelNames {
out[i] = n.name
}
return out
}
// Mark labels a block of a rule. Marks set no obligation, they only say what
// this is: a rationale, an illustration, a note about mechanization, a stub in
// place of a retired rule.
type Mark int
const (
Rationale Mark = iota + 1
Examples
Mechanized
Retired
)
func (m Mark) String() string {
switch m {
case Rationale:
return "rationale"
case Examples:
return "examples"
case Mechanized:
return "mechanized"
case Retired:
return "retired"
}
return "unknown mark"
}
// Connective is a service word of a scenario block. Such words stay out of the
// language version line and out of the "modal words outside rules" check: they
// set no obligation, only structure.
type Connective int
const (
When Connective = iota + 1
Then
And
Or
)
// Vocabulary is the vocabulary of one language version in one natural language.
type Vocabulary struct {
Version int
Code string
Modals map[string]Level
Marks map[string]Mark
Scenario map[string]Connective
// Line is the language version line, with a single verb for the version
// number. Every convention names the language by one such line, and the
// wording around the words is as much a property of the natural language
// as the words themselves — which is why it lives here and not in a
// template inside the command that writes a new file.
Line string
}
// VersionLine renders the language version line for this vocabulary.
func (v Vocabulary) VersionLine() string {
return fmt.Sprintf(v.Line, v.Version)
}
// Modal reports the step of a word if the word belongs to this scale.
func (v Vocabulary) Modal(word string) (Level, bool) {
l, ok := v.Modals[word]
return l, ok
}
// Mark reports the role of a mark if the word belongs to these marks.
func (v Vocabulary) Mark(word string) (Mark, bool) {
m, ok := v.Marks[word]
return m, ok
}
// Word returns the word this vocabulary uses for a step.
func (v Vocabulary) Word(l Level) string {
for w, got := range v.Modals {
if got == l {
return w
}
}
return ""
}
// MarkWord returns the word this vocabulary uses for a mark.
func (v Vocabulary) MarkWord(m Mark) string {
for w, got := range v.Marks {
if got == m {
return w
}
}
return ""
}
// Lead returns the vocabulary word that opens text. The longest match wins:
// "MUST NOT" must not be read as "MUST", and a mark carrying a date
// ("RETIRED 2026-07-26") must not be read as a different mark.
func (v Vocabulary) Lead(text string) (string, bool) {
best := ""
for _, w := range v.Words() {
if len(w) <= len(best) {
continue
}
if !hasWordPrefix(text, w) {
continue
}
best = w
}
return best, best != ""
}
// Words lists every word the language names in its version line: the steps of
// the scale and the marks. Scenario connectives are not among them.
func (v Vocabulary) Words() []string {
words := make([]string, 0, len(v.Modals)+len(v.Marks))
for w := range v.Modals {
words = append(words, w)
}
for w := range v.Marks {
words = append(words, w)
}
sort.Strings(words)
return words
}
// hasWordPrefix reports whether text starts with word w and the word ends
// there: "MUSTARD" is not the word "MUST".
func hasWordPrefix(text, w string) bool {
if !strings.HasPrefix(text, w) {
return false
}
rest := text[len(w):]
if rest == "" {
return true
}
r, _ := utf8.DecodeRuneInString(rest)
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}
// registry holds the vocabularies known to the binary. The outer key is the
// language version, the inner one the code of the suite's natural language.
var registry = map[int]map[string]Vocabulary{
1: {
"ru": {
Version: 1,
Code: "ru",
Modals: map[string]Level{
"ДОЛЖЕН": Requirement,
"НЕ ДОЛЖЕН": Prohibition,
"СЛЕДУЕТ": Recommendation,
"НЕ СЛЕДУЕТ": RecommendationAgainst,
"ДОПУСКАЕТСЯ": Permission,
},
Marks: map[string]Mark{
"ПОЧЕМУ": Rationale,
"ПРИМЕРЫ": Examples,
"МЕХАНИЗИРОВАНО": Mechanized,
"СНЯТО": Retired,
},
Scenario: map[string]Connective{
"КОГДА": When,
"ТОГДА": Then,
"И": And,
"ИЛИ": Or,
},
Line: "Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки\n" +
"ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке\n" +
"конвенций версии %d — тогда и только тогда, когда написаны заглавными.",
},
"en": {
Version: 1,
Code: "en",
Modals: map[string]Level{
"MUST": Requirement,
"MUST NOT": Prohibition,
"SHOULD": Recommendation,
"SHOULD NOT": RecommendationAgainst,
"MAY": Permission,
},
Marks: map[string]Mark{
"WHY": Rationale,
"EXAMPLES": Examples,
"MECHANIZED": Mechanized,
"RETIRED": Retired,
},
Scenario: map[string]Connective{
"WHEN": When,
"THEN": Then,
"AND": And,
"OR": Or,
},
Line: "The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY,\n" +
"EXAMPLES, MECHANIZED and RETIRED are to be interpreted as described in the\n" +
"conventions language, version %d, and only when written in capitals.",
},
},
}
// Lookup returns the vocabulary of a language version in the given natural
// language.
func Lookup(version int, code string) (Vocabulary, error) {
byCode, ok := registry[version]
if !ok {
return Vocabulary{}, fmt.Errorf("language version %d is unknown to the tool; known versions: %s", version, versions())
}
v, ok := byCode[code]
if !ok {
return Vocabulary{}, fmt.Errorf("vocabulary %q of language version %d is unknown to the tool; known: %s", code, version, codes(version))
}
return v, nil
}
// Foreign lists the words of the other vocabularies of the same version — the
// ones that give away a mixture of vocabularies. Words that coincide with the
// suite's own are dropped.
func Foreign(version int, code string) map[string]string {
own, err := Lookup(version, code)
if err != nil {
return nil
}
mine := make(map[string]bool)
for _, w := range own.Words() {
mine[w] = true
}
foreign := make(map[string]string)
for otherCode, other := range registry[version] {
if otherCode == code {
continue
}
for _, w := range other.Words() {
if !mine[w] {
foreign[w] = otherCode
}
}
}
return foreign
}
// ForeignConnectives lists the scenario connectives of the other vocabularies
// of the same version. They are kept apart from Foreign because they are caught
// differently: a connective is a whole word of everyday speech in some
// language — AND and OR are SQL keywords too — so only its position at the
// start of a line tells a scenario block from a mention.
func ForeignConnectives(version int, code string) map[string]string {
own, err := Lookup(version, code)
if err != nil {
return nil
}
foreign := make(map[string]string)
for otherCode, other := range registry[version] {
if otherCode == code {
continue
}
for w := range other.Scenario {
if _, mine := own.Scenario[w]; mine {
continue
}
foreign[w] = otherCode
}
}
return foreign
}
func versions() string {
out := make([]string, 0, len(registry))
for v := range registry {
out = append(out, fmt.Sprint(v))
}
sort.Strings(out)
return strings.Join(out, ", ")
}
func codes(version int) string {
out := make([]string, 0, len(registry[version]))
for c := range registry[version] {
out = append(out, c)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
+74
View File
@@ -0,0 +1,74 @@
package lang_test
import (
"testing"
"git.vakhrushev.me/av/convy/internal/lang"
)
func TestLeadTakesLongestMatch(t *testing.T) {
v, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
cases := []struct {
text string
want string
}{
{"ДОЛЖЕН.", "ДОЛЖЕН"},
{"НЕ ДОЛЖЕН.", "НЕ ДОЛЖЕН"},
{"НЕ СЛЕДУЕТ.", "НЕ СЛЕДУЕТ"},
{"СЛЕДУЕТ.", "СЛЕДУЕТ"},
{"СНЯТО 2026-07-26.", "СНЯТО"},
{"ПОЧЕМУ.", "ПОЧЕМУ"},
{"", ""},
{"Заголовок правила", ""},
{"ДОЛЖЕНСТВОВАНИЕ", ""},
}
for _, tc := range cases {
got, ok := v.Lead(tc.text)
if tc.want == "" {
if ok {
t.Errorf("Lead(%q) = %q, wanted no word to be found", tc.text, got)
}
continue
}
if !ok || got != tc.want {
t.Errorf("Lead(%q) = %q, %v; wanted %q", tc.text, got, ok, tc.want)
}
}
}
func TestForeignExcludesOwnWords(t *testing.T) {
foreign := lang.Foreign(1, "ru")
if len(foreign) == 0 {
t.Fatal("the Russian vocabulary yielded no foreign word at all")
}
if code, ok := foreign["MUST"]; !ok || code != "en" {
t.Errorf("MUST should be recognized as a word of the en vocabulary, got %q, %v", code, ok)
}
v, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
for word := range foreign {
if _, own := v.Modal(word); own {
t.Errorf("the word %q is the suite's own, yet landed among the foreign ones", word)
}
if _, own := v.Mark(word); own {
t.Errorf("the mark %q is the suite's own, yet landed among the foreign ones", word)
}
}
}
func TestUnknownVersionAndCode(t *testing.T) {
if _, err := lang.Lookup(99, "ru"); err == nil {
t.Error("an unknown language version was accepted without an error")
}
if _, err := lang.Lookup(1, "xx"); err == nil {
t.Error("an unknown vocabulary was accepted without an error")
}
}
+152
View File
@@ -0,0 +1,152 @@
package manifest
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
)
// The manifest is edited as text rather than decoded and written back.
//
// suite.toml carries more comment than data — the reasoning behind every topic
// and every prefix lives there, and an encoder would drop all of it and reorder
// what is left. So an entry is spliced into the source, and everything the
// author wrote around it survives untouched.
var (
tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`)
keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`)
bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// AddEntry splices key = "value" into the given table of a TOML source.
//
// Where the entry lands follows what the table already does: a table whose keys
// are in alphabetical order keeps it, and one ordered by hand — by directory,
// by age, by whatever the author meant — gets the entry appended, because
// guessing at that order would scatter it.
func AddEntry(source []byte, table, key, value string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
if slices.Contains(keys, key) {
return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key)
}
entry := renderEntry(key, value)
insert := insertionPoint(keys, at, key, lines, start, end)
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:insert]...)
out = append(out, entry)
out = append(out, lines[insert:]...)
return []byte(strings.Join(out, "\n")), nil
}
// RemoveEntry drops a key from a table, leaving everything around it alone.
// Together with AddEntry it moves an entry from the live half of a section to
// the retired one, which is the only way a name ever leaves the live half.
func RemoveEntry(source []byte, table, key string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
for i, existing := range keys {
if existing != key {
continue
}
out := make([]string, 0, len(lines)-1)
out = append(out, lines[:at[i]]...)
out = append(out, lines[at[i]+1:]...)
return []byte(strings.Join(out, "\n")), nil
}
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
}
// tableKeys collects the keys of a table together with the line each sits on.
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
for i := from; i < to; i++ {
m := keyRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
keys = append(keys, strings.Trim(m[1], `"`))
at = append(at, i)
}
return keys, at
}
// insertionPoint picks the line the entry goes before.
func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int {
if len(keys) == 0 {
// An empty table owns the comments standing right under its header
// and nothing further: a comment block separated by a blank line
// belongs to the table header below it, not to this one. Walking to
// the end of the section instead would file the entry under the wrong
// explanation.
i := start + 1
for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
i++
}
return i
}
if sort.StringsAreSorted(keys) {
for i, existing := range keys {
if key < existing {
return at[i]
}
}
}
return at[len(at)-1] + 1
}
// renderEntry writes one key-value line, quoting the key when it is not bare.
func renderEntry(key, value string) string {
if !bareRe.MatchString(key) {
key = `"` + escape(key) + `"`
}
return key + ` = "` + escape(value) + `"`
}
func escape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
+130
View File
@@ -0,0 +1,130 @@
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)
}
}
+181
View File
@@ -0,0 +1,181 @@
// Package manifest reads suite.toml, the manifest of a conventions suite.
//
// The 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.
package manifest
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/BurntSushi/toml"
)
// Name is the name of a suite manifest. Which of the two manifests lies next
// to you tells you where you are: suite.toml means a suite, .conventions.toml
// means a project.
const Name = "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.
type Language struct {
Version int `toml:"version"`
Lang string `toml:"lang"`
Description string `toml:"description"`
Reading string `toml:"reading"`
}
// 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"`
}
// Manifest is a parsed suite.toml.
type Manifest struct {
Language Language `toml:"language"`
Topics Section `toml:"topics"`
Prefixes Section `toml:"prefixes"`
// Path is where the manifest was read from.
Path string `toml:"-"`
// Undecoded lists keys the tool does not know. A typo in the manifest
// would otherwise pass in silence, and it costs a subscription or a
// whole file.
Undecoded []string `toml:"-"`
}
// Load reads the suite manifest from directory root.
func Load(root string) (*Manifest, error) {
path := filepath.Join(root, Name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading the suite manifest: %w", err)
}
var m Manifest
meta, err := toml.Decode(string(data), &m)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
m.Path = path
for _, key := range meta.Undecoded() {
m.Undecoded = append(m.Undecoded, key.String())
}
sort.Strings(m.Undecoded)
if m.Language.Lang == "" {
m.Language.Lang = DefaultLanguageCode
}
return &m, nil
}
// 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) {
dir, err := filepath.Abs(start)
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, Name)); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", ErrNotFound
}
dir = parent
}
}
// ErrNotFound means there is no suite manifest here or above.
var ErrNotFound = errors.New("suite manifest not found")
// LivePrefixes lists the live prefixes in an order stable between runs: the
// output of a check must not depend on map iteration.
func (m *Manifest) LivePrefixes() []string {
return sortedKeys(m.Prefixes.Live)
}
// LiveTopics lists the live topics in a stable order.
func (m *Manifest) LiveTopics() []string {
return sortedKeys(m.Topics.Live)
}
// PrefixOf returns the prefix declared for a file, if there is one. Paths are
// compared in slash form, the way the manifest writes them.
func (m *Manifest) PrefixOf(path string) (string, bool) {
want := filepath.ToSlash(path)
for prefix, declared := range m.Prefixes.Live {
if filepath.ToSlash(declared) == want {
return prefix, true
}
}
return "", false
}
// PathOf returns the path declared for a live prefix.
func (m *Manifest) PathOf(prefix string) (string, bool) {
path, ok := m.Prefixes.Live[prefix]
return path, ok
}
// TopicLive reports whether the topic is declared among the live ones.
func (m *Manifest) TopicLive(topic string) bool {
_, ok := m.Topics.Live[topic]
return ok
}
// TopicRetired reports whether the topic is listed among the retired ones.
func (m *Manifest) TopicRetired(topic string) bool {
_, ok := m.Topics.Retired[topic]
return ok
}
// PrefixRetired reports whether the prefix is listed among the retired ones.
func (m *Manifest) PrefixRetired(prefix string) bool {
_, ok := m.Prefixes.Retired[prefix]
return ok
}
// ValidPrefix checks the shape of a prefix: four uppercase Latin letters. The
// letter X in first position is reserved for consuming repositories, and the
// suite never takes it.
func ValidPrefix(prefix string) error {
if len(prefix) != 4 {
return fmt.Errorf("prefix %q is not four letters", prefix)
}
for _, r := range prefix {
if r < 'A' || r > 'Z' {
return fmt.Errorf("prefix %q holds a character that is not an uppercase Latin letter", prefix)
}
}
if strings.HasPrefix(prefix, "X") {
return fmt.Errorf("prefix %q starts with X, a letter reserved for the local rules of consumers", prefix)
}
return nil
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+71
View File
@@ -0,0 +1,71 @@
package suite
import "git.vakhrushev.me/av/convy/internal/doc"
// 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.
type Component struct {
Lang string
Stack string
}
// Assemble picks the layers of a topic a component takes, in the order they go
// into a copy: base, then language, then stack.
//
// A layer is chosen by the axis keys of its front matter rather than by where
// its file lies (META-38). A layer with no keys is the base one and travels
// always; a key the layer does not declare puts no demand on the component, so
// a language layer with no stack key fits any stack.
//
// This is the read-only half of assembly. The project commands write files out
// of the same selection, so the two must never come to differ — which is why
// the selection lives here rather than inside whichever command needs it.
func (s *Suite) Assemble(topic string, c Component) (taken, left []*doc.Document) {
for _, d := range s.Layers(topic) {
if fits(d, c) {
taken = append(taken, d)
continue
}
left = append(left, d)
}
return taken, left
}
// fits reports whether a component takes a layer.
func fits(d *doc.Document, c Component) bool {
if d.Front.Lang != "" && d.Front.Lang != c.Lang {
return false
}
if d.Front.Stack != "" && d.Front.Stack != c.Stack {
return false
}
return true
}
// rank orders the layers of a topic the way a copy carries them. A layer on
// both axes comes last: it narrows the most.
func rank(d *doc.Document) int {
switch {
case d.Front.Lang == "" && d.Front.Stack == "":
return 0
case d.Front.Stack == "":
return 1
case d.Front.Lang == "":
return 2
}
return 3
}
// Axis describes a layer in one word, for a report.
func Axis(d *doc.Document) string {
switch {
case d.Front.Lang != "" && d.Front.Stack != "":
return "lang=" + d.Front.Lang + " stack=" + d.Front.Stack
case d.Front.Lang != "":
return "lang=" + d.Front.Lang
case d.Front.Stack != "":
return "stack=" + d.Front.Stack
}
return "base"
}
+186
View File
@@ -0,0 +1,186 @@
// Package suite assembles a conventions suite in memory: its manifest, the
// vocabulary of its language, and the documents the language employs.
//
// What is subject to checking is everything the language employs: the
// convention files and the document the suite governs itself by. The list of
// those files comes from the manifest, from the section of live prefixes where
// every prefix carries a path. A file the language merely quotes — the
// description of the language itself — is not listed in the suite and gets no
// checks.
package suite
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// Suite is a loaded suite.
type Suite struct {
Root string
Manifest *manifest.Manifest
Vocab lang.Vocabulary
// Docs holds the documents of the suite in the order of live prefixes.
Docs []*doc.Document
// ByPrefix maps a manifest prefix to its document.
ByPrefix map[string]*doc.Document
// Missing lists the prefixes whose file the manifest declares while the
// file system holds none.
Missing map[string]string
// Unregistered lists files found in the suite carrying front matter that
// the manifest does not declare. Such a file is neither checked nor
// assembled: for the suite it does not exist, however its author sees it.
Unregistered []string
// Broken lists files whose front matter could not be parsed.
Broken []error
}
// Load reads a suite from directory root.
func Load(root string) (*Suite, error) {
m, err := manifest.Load(root)
if err != nil {
return nil, err
}
vocab, err := lang.Lookup(m.Language.Version, m.Language.Lang)
if err != nil {
return nil, fmt.Errorf("%s: %w", m.Path, err)
}
s := &Suite{
Root: root,
Manifest: m,
Vocab: vocab,
ByPrefix: make(map[string]*doc.Document),
Missing: make(map[string]string),
}
for _, prefix := range m.LivePrefixes() {
rel := filepath.ToSlash(m.Prefixes.Live[prefix])
name := filepath.Join(root, filepath.FromSlash(rel))
d, err := doc.Load(rel, name)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
s.Missing[prefix] = rel
continue
}
s.Broken = append(s.Broken, err)
continue
}
d.Blocks(vocab)
s.Docs = append(s.Docs, d)
s.ByPrefix[prefix] = d
}
if err := s.findUnregistered(); err != nil {
return nil, err
}
return s, nil
}
// findUnregistered walks the suite looking for files written in the conventions
// language yet absent from the manifest. The marker is the prefix key in the
// front matter rather than the location of the file: the suite rearranges its
// taxonomy, while the front matter asserts.
func (s *Suite) findUnregistered() error {
// Only live prefixes declare a path. A retired entry records why a prefix
// left and when, not where a file lies — retiring a prefix means the file
// went with it, so one left behind is undeclared and has to say so.
declared := make(map[string]bool)
for _, path := range s.Manifest.Prefixes.Live {
declared[filepath.ToSlash(path)] = true
}
err := filepath.WalkDir(s.Root, func(name string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
if strings.HasPrefix(entry.Name(), ".") && name != s.Root {
return fs.SkipDir
}
return nil
}
if filepath.Ext(entry.Name()) != ".md" {
return nil
}
rel, err := filepath.Rel(s.Root, name)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if declared[rel] {
return nil
}
d, err := doc.Load(rel, name)
if err != nil {
// The file carries no front matter, or it is broken — for the
// suite this is not a document of the language but plain
// markdown lying nearby.
return nil
}
if d.Front.Prefix != "" {
s.Unregistered = append(s.Unregistered, rel)
}
return nil
})
if err != nil {
return fmt.Errorf("walking the suite: %w", err)
}
sort.Strings(s.Unregistered)
return nil
}
// Conventions picks out the convention documents — the ones that carry a topic
// and therefore travel to a consumer. A document without a topic, the one the
// suite governs itself by, gets no spread checks.
func (s *Suite) Conventions() []*doc.Document {
var out []*doc.Document
for _, d := range s.Docs {
if d.Front.Topic != "" {
out = append(out, d)
}
}
return out
}
// Layers lists the layers of a topic — the documents that declared that name in
// their front matter — in the order a copy carries them: base, then language,
// then stack. Documents are loaded in the order of their prefixes, which says
// nothing about layers, and a listing that opens with a language layer reads as
// if the base one were missing.
func (s *Suite) Layers(topic string) []*doc.Document {
var out []*doc.Document
for _, d := range s.Docs {
if d.Front.Topic == topic {
out = append(out, d)
}
}
sort.Slice(out, func(i, j int) bool {
if rank(out[i]) != rank(out[j]) {
return rank(out[i]) < rank(out[j])
}
return out[i].Path < out[j].Path
})
return out
}
// Prefix returns the prefix the manifest declares for a document.
func (s *Suite) Prefix(d *doc.Document) string {
prefix, _ := s.Manifest.PrefixOf(d.Path)
return prefix
}
// Exists reports whether the suite holds a file at a path from its root.
func (s *Suite) Exists(rel string) bool {
_, err := os.Stat(filepath.Join(s.Root, filepath.FromSlash(rel)))
return err == nil
}
+13
View File
@@ -0,0 +1,13 @@
// Command convy tends development conventions: it checks the integrity of a
// suite and assembles copies inside projects.
package main
import (
"os"
"git.vakhrushev.me/av/convy/internal/cli"
)
func main() {
os.Exit(cli.Main())
}