Compare commits
9
Commits
c557e72c79
...
4615de6e86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4615de6e86
|
||
|
|
8331aa1ca5
|
||
|
|
5321fba89d
|
||
|
|
b29b5b5e6f
|
||
|
|
709157237d
|
||
|
|
51d2050200
|
||
|
|
b2d07ae55d
|
||
|
|
0b8cc125b3
|
||
|
|
ccf046fb7b
|
@@ -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 нет.
|
||||||
@@ -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;
|
||||||
|
- не хранит список подписчиков: подписка — свойство проекта;
|
||||||
|
- не переносит правки из проекта в набор: операция ручная и редкая;
|
||||||
|
- не перенумеровывает правила: номер — идентификатор, а не позиция.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
module git.vakhrushev.me/av/convy
|
module git.vakhrushev.me/av/convy
|
||||||
|
|
||||||
go 1.26.5
|
go 1.26.5
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v1.6.0
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, `"`, `\"`)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user