From ccf046fb7bb537c38b73b634b59dd454f304f9ed Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 27 Jul 2026 09:53:07 +0300 Subject: [PATCH] =?UTF-8?q?suite=20check:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D1=86=D0=B5=D0=BB=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста --- go.mod | 2 + go.sum | 2 + internal/check/check_test.go | 317 ++++++++++++++++++++++++++ internal/check/form.go | 337 +++++++++++++++++++++++++++ internal/check/layers_test.go | 236 +++++++++++++++++++ internal/check/refs.go | 114 ++++++++++ internal/check/report.go | 106 +++++++++ internal/check/spread.go | 180 +++++++++++++++ internal/check/suite.go | 118 ++++++++++ internal/check/text.go | 41 ++++ internal/cli/cli.go | 104 +++++++++ internal/cli/suitecheck.go | 97 ++++++++ internal/doc/doc.go | 376 +++++++++++++++++++++++++++++++ internal/doc/front.go | 80 +++++++ internal/lang/vocabulary.go | 281 +++++++++++++++++++++++ internal/lang/vocabulary_test.go | 73 ++++++ internal/manifest/manifest.go | 178 +++++++++++++++ internal/suite/suite.go | 173 ++++++++++++++ main.go | 13 ++ 19 files changed, 2828 insertions(+) create mode 100644 go.sum create mode 100644 internal/check/check_test.go create mode 100644 internal/check/form.go create mode 100644 internal/check/layers_test.go create mode 100644 internal/check/refs.go create mode 100644 internal/check/report.go create mode 100644 internal/check/spread.go create mode 100644 internal/check/suite.go create mode 100644 internal/check/text.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/suitecheck.go create mode 100644 internal/doc/doc.go create mode 100644 internal/doc/front.go create mode 100644 internal/lang/vocabulary.go create mode 100644 internal/lang/vocabulary_test.go create mode 100644 internal/manifest/manifest.go create mode 100644 internal/suite/suite.go create mode 100644 main.go diff --git a/go.mod b/go.mod index d6c3496..715bf6c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.vakhrushev.me/av/convy go 1.26.5 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/check/check_test.go b/internal/check/check_test.go new file mode 100644 index 0000000..b80a77e --- /dev/null +++ b/internal/check/check_test.go @@ -0,0 +1,317 @@ +package check_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.vakhrushev.me/av/convy/internal/check" + "git.vakhrushev.me/av/convy/internal/suite" +) + +// versionLine — строка о версии языка. Она перечисляет ключевые слова набора, +// поэтому единственная законно несёт модальные слова вне правил. +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. + +**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана. +` + +// files — содержимое набора: путь от корня к тексту файла. Пустая строка +// значит «файла нет»: так тест снимает файл, который есть в основе. +type files map[string]string + +func base() files { + return files{ + "suite.toml": baseManifest, + "LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n", + "READING.md": "# Как читать конвенцию\n\nКоротко.\n", + "conventions/time.md": baseTime, + } +} + +// run записывает набор во временную директорию и прогоняет проверки. +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("загрузка набора: %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("исправный набор дал находки:\n%s", messages(got)) + } +} + +// rule собирает правило целиком, чтобы тесты не повторяли его форму. +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: "префикс в шапке расходится с манифестом", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1) + }, + want: "префикс в шапке — GTIM, а манифест объявляет за этим файлом TIME", + }, { + name: "заголовок правила несёт чужой префикс", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1) + }, + want: "использует префикс GTIM, а файлу принадлежит TIME", + }, { + name: "нумерация с дырой", + setup: func(f files) { + f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.") + }, + want: "нумерация не сплошная", + }, { + name: "номер занят дважды", + setup: func(f files) { + f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.") + }, + want: "номер TIME-1 занят дважды", + }, { + name: "правило без обоснования", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1) + }, + want: "нет блока ПОЧЕМУ: обоснование обязательно", + }, { + name: "правило без нормы и без заглушки", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1) + }, + want: "нет ни блока нормы, ни заглушки СНЯТО", + }, { + name: "две нормы под одним номером", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1) + }, + want: "две нормы (ДОЛЖЕН и СЛЕДУЕТ)", + }, { + name: "обоснование стоит раньше нормы", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", + "**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1) + }, + want: "обоснование стоит раньше нормы", + }, { + name: "примеры стоят раньше обоснования", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1) + }, + want: "блок ПРИМЕРЫ стоит раньше обоснования", + }, { + name: "заглушка снятого без даты", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", + "**СНЯТО.** Правило убрано за ненадобностью.", 1) + }, + want: "не несёт даты снятия", + }, { + name: "у снятого правила осталась норма", + setup: func(f files) { + f["conventions/time.md"] = baseTime + + "\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n" + }, + want: "остался блок нормы", + }, { + name: "нет строки о версии языка", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1) + }, + want: "нет строки о версии языка", + }, { + name: "строка о версии называет чужую версию", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1) + }, + want: "не называет версию 1", + }, { + name: "модальное слово вне области правила", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1) + }, + want: "стоит вне области правила", + }, { + name: "слово чужого словаря", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1) + }, + want: `слово MUST принадлежит словарю "en"`, + }, { + name: "ссылка на несуществующее правило", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "Без явного смещения", "Смотри TIME-9. Без явного смещения", 1) + }, + want: "ссылка TIME-9 не разрешается", + }, { + name: "ссылка на неизвестный префикс", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1) + }, + want: "префикс ZZZZ, которого в манифесте набора нет", + }, { + name: "тема не объявлена в манифесте", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1) + }, + want: `тема "clocks" не объявлена`, + }, { + name: "тема значится среди выбывших", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, + "[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1) + }, + want: "значится и среди живых, и среди выбывших", + }, { + name: "у живой темы нет слоёв", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, + `time = "время: хранение, зоны, форматы"`, + `time = "время"`+"\nlogging = \"логирование\"", 1) + }, + want: `тема "logging" объявлена живой, а слоёв у неё в наборе нет`, + }, { + name: "объявленного файла нет", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, + `TIME = "conventions/time.md"`, + `TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1) + }, + want: `префикс SLOG объявлен за файлом "conventions/logging.md", а файла в наборе нет`, + }, { + name: "файл не зарегистрирован в манифесте", + setup: func(f files) { + f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n" + }, + want: "в манифесте набора не объявлен", + }, { + name: "префикс начинается на 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: "зарезервирована за локальными правилами потребителей", + }, { + name: "на один файл объявлено два префикса", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, + `TIME = "conventions/time.md"`, + `TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1) + }, + want: "объявлено несколько префиксов", + }, { + name: "ключ манифеста неизвестен", + setup: func(f files) { + f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n" + }, + want: "инструменту неизвестен", + }, { + name: "метка механизации в тексте конвенции", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1) + }, + want: "её место — запись о механизации в локальной части копии", + }, { + name: "путь канона в тексте конвенции", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, + "Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1) + }, + want: "стоит путь файла канона", + }} + + 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("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got) + } + }) + } +} diff --git a/internal/check/form.go b/internal/check/form.go new file mode 100644 index 0000000..bf0d60b --- /dev/null +++ b/internal/check/form.go @@ -0,0 +1,337 @@ +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 проверяет форму правила разбором текста. Применяется к любому +// файлу, который язык употребляет, — и к конвенциям, и к документу, которым +// набор ведёт себя сам. +func checkForm(s *suite.Suite, d *doc.Document, rep *Report) { + prefix := checkFilePrefix(s, d, rep) + checkHeadings(d, prefix, 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) +} + +// checkFilePrefix сверяет префикс в шапке с манифестом и возвращает префикс, +// которым файлу положено пользоваться. +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, "у файла нет шапки, а манифест объявляет за ним префикс %s", declared) + return declared + } + if d.Front.Prefix == "" { + rep.Errorf(Form, d.Path, 1, "шапка не несёт ключа prefix") + return declared + } + at := d.Front.At["prefix"] + if d.Front.Prefix != declared { + rep.Errorf(Form, d.Path, at, + "префикс в шапке — %s, а манифест объявляет за этим файлом %s", 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, "префикс %s значится среди выбывших", d.Front.Prefix) + } + for _, key := range d.Front.Unknown { + rep.Warnf(Form, d.Path, d.Front.At[key], "ключ шапки %q инструменту неизвестен", key) + } + return declared +} + +// checkHeadings проверяет форму заголовков правил: собственный префикс файла, +// третий уровень, точка после идентификатора, название. +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, + "заголовок правила использует префикс %s, а файлу принадлежит %s", r.Prefix, prefix) + } + if r.HeadingLevel != 3 { + rep.Errorf(Form, d.Path, r.Line, + "заголовок правила %s стоит на уровне %d, а правило — заголовок третьего уровня", r.ID(), r.HeadingLevel) + } + if r.Malformed != "" { + rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed) + } + } +} + +// checkNumbering проверяет сплошную нумерацию: от единицы до наибольшего без +// пропусков и без повторов (META-31). Дыра неотличима от опечатки в номере и +// от правила, которое забыли дописать, — поэтому её нет никогда, а снятое +// правило остаётся заглушкой. +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], + "номер %s занят дважды: строки %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, + "нумерация не сплошная: наибольший номер %d, пропущены %s — снятое правило остаётся заглушкой, а не исчезает", + highest, joinInts(gaps)) + } +} + +var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`) + +// checkRules проверяет состав правила: либо норма с обоснованием, либо +// заглушка снятого. Ни норма, ни обоснование не удаляются никогда +// (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, + "у правила %s нет ни блока нормы, ни заглушки %s", r.ID(), v.MarkWord(lang.Retired)) + case 1: + if norms[0].Rest == "" { + rep.Errorf(Form, d.Path, norms[0].Start, + "у правила %s метка %s не открывает нормы: за ней пусто", r.ID(), norms[0].Word) + } + default: + rep.Errorf(Form, d.Path, norms[1].Start, + "у правила %s две нормы (%s и %s): норма — одна фраза, иначе нарушение одной её половины нечем адресовать", + r.ID(), norms[0].Word, norms[1].Word) + } + + rationale, ok := r.Block(lang.Rationale) + if !ok { + rep.Errorf(Form, d.Path, r.Line, + "у правила %s нет блока %s: обоснование обязательно", r.ID(), v.MarkWord(lang.Rationale)) + } else if len(norms) > 0 && rationale.Start < norms[0].Start { + rep.Errorf(Form, d.Path, rationale.Start, + "у правила %s обоснование стоит раньше нормы: порядок блоков — норма, %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, + "у правила %s блок %s открывает правило: порядок блоков — норма, %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, + "у правила %s блок %s стоит раньше обоснования: сначала требование, потом причина, потом иллюстрация", + r.ID(), v.MarkWord(lang.Examples)) + } + } + } +} + +// checkRetired проверяет заглушку снятого правила: дата и причина. +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, + "у снятого правила %s остался блок нормы: норму с обоснованием заменяет заглушка", r.ID()) + } + if !dateRe.MatchString(d.Line(retired.Start)) { + rep.Errorf(Form, d.Path, retired.Start, + "заглушка правила %s не несёт даты снятия", r.ID()) + } + if strings.TrimSpace(retired.Rest) == "" { + rep.Errorf(Form, d.Path, retired.Start, + "заглушка правила %s не несёт причины снятия", r.ID()) + } +} + +// checkVersionLine ищет во вводной прозе строку о версии языка и возвращает +// границы абзаца, который её несёт. +// +// Строка перечисляет ключевые слова набора и сама несёт правило заглавных — +// поэтому она единственное место вне правил, где модальные слова законны. +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, + "во вводной прозе нет строки о версии языка: она перечисляет ключевые слова набора и без неё конвенция в чужом репозитории теряет ключ к собственному тексту") + return 0, 0 + } + version := strconv.Itoa(s.Manifest.Language.Version) + if !containsNumber(p.Text(), version) { + rep.Errorf(Form, d.Path, p.Start, + "строка о версии языка не называет версию %s, объявленную манифестом набора", version) + } + return p.Start, p.End +} + +// versionParagraph ищет во вводной прозе абзац, несущий строку о версии языка: +// тот, где перечислены все ключевые слова набора. Ничего не сообщает — о его +// отсутствии говорит checkVersionLine, и говорить дважды незачем. +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 ищет заглавные модальные слова вне областей правил. +// Область — от заголовка правила до следующего заголовка; всё остальное проза, +// а проза нормой не является никогда. +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, + "модальное слово %s стоит вне области правила: заглавное написание нормативно, и в прозе его быть не может", w) + break + } + return true + }) +} + +// checkForeignVocabulary ищет слова чужого словаря той же версии языка. +// Словарь один на набор: две формы записи одного требования удваивают каждую +// проверку. +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, + "слово %s принадлежит словарю %q, а набор объявляет словарь %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 ищет слово как целое: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не +// является, а «ДОЛЖЕН.» — является. +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, ", ") +} diff --git a/internal/check/layers_test.go b/internal/check/layers_test.go new file mode 100644 index 0000000..4f8260d --- /dev/null +++ b/internal/check/layers_test.go @@ -0,0 +1,236 @@ +package check_test + +import ( + "strings" + "testing" +) + +// Набор с двумя слоями одной темы: базовый арх-слой и языковой поверх него. +// На нём проверяется всё, что про оси, extends и границу самодостаточности +// нормы, — на одном слое эти проверки выразить нечем. + +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": "# Как читать конвенцию\n\nКоротко.\n", + "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("исправный многослойный набор дал находки:\n%s", messages(got)) + } +} + +func TestLayeredChecks(t *testing.T) { + cases := []struct { + name string + setup func(files) + want string + }{{ + name: "ось в шапке расходится с путём", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1) + }, + want: `путь кладёт файл на ось lang=go, а шапка объявляет lang="python"`, + }, { + name: "extends ведёт в чужую тему", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, + "extends: arch/time.md", "extends: arch/logging.md", 1) + }, + want: `с темой "logging", а файл несёт тему "time"`, + }, { + name: "extends ведёт в несуществующий файл", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, + "extends: arch/time.md", "extends: arch/clocks.md", 1) + }, + want: `а такого файла в наборе нет`, + }, { + name: "у темы два слоя без ключей оси", + 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: "больше одного слоя без ключей оси", + }, { + name: "норма ссылается на префикс чужой темы", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, + "**ДОЛЖЕН.** Текущее время приходит из store.Now().", + "**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1) + }, + want: `ссылается на SLOG-1 из чужой темы "logging"`, + }, { + name: "норма ссылается на неба́зовый слой своей темы", + setup: func(f files) { + f["conventions/arch/time.md"] = strings.Replace(archTime, + "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", + "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1) + }, + want: "слой своей темы, но не базовый", + }} + + 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("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got) + } + }) + } +} + +// TestNoFalsePositives собирает случаи, в которых проверка обязана промолчать. +// Ложное срабатывание здесь дороже пропуска: проверку, которая краснеет на +// исправном файле, выключают целиком. +func TestNoFalsePositives(t *testing.T) { + cases := []struct { + name string + setup func(files) + }{{ + name: "идентификатор в бэктиках — образец записи, а не ссылка", + setup: func(f files) { + f["conventions/arch/time.md"] = strings.Replace(archTime, + "Без явного смещения", + "На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1) + }, + }, { + name: "модальное слово внутри огороженного блока кода", + setup: func(f files) { + f["conventions/arch/time.md"] = archTime + + "\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n" + }, + }, { + name: "заглавное SQL-слово в примере кода", + 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: "норма языкового слоя ссылается на базовый слой своей темы", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, + "**ДОЛЖЕН.** Текущее время приходит из store.Now().", + "**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1) + }, + }, { + name: "упоминание ступени в обосновании — не вторая норма", + setup: func(f files) { + f["conventions/arch/time.md"] = strings.Replace(archTime, + "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", + "**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1) + }, + }, { + name: "заглушка снятого правила с датой и причиной", + setup: func(f files) { + f["conventions/arch/time.md"] = archTime + + "\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\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("проверка сработала там, где не должна:\n%s", messages(got)) + } + }) + } +} diff --git a/internal/check/refs.go b/internal/check/refs.go new file mode 100644 index 0000000..794b3e9 --- /dev/null +++ b/internal/check/refs.go @@ -0,0 +1,114 @@ +package check + +import ( + "regexp" + "strconv" + "strings" + + "git.vakhrushev.me/av/convy/internal/doc" + "git.vakhrushev.me/av/convy/internal/suite" +) + +// refRe ловит идентификатор правила: четыре заглавные латинские буквы, дефис, +// номер и необязательный номер строки таблицы. +var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`) + +// Ref — ссылка на правило, найденная в тексте. +type Ref struct { + Prefix string + Num int + Sub int + Line int + Text string +} + +// refsIn собирает ссылки в диапазоне строк документа. Инлайн-код вырезан: в +// бэктиках идентификатор стоит образцом записи, а не ссылкой на утверждение, — +// иначе строка «на конкретное правило ссылаются идентификатором (`SLOG-27`)» +// требовала бы, чтобы правило SLOG-27 существовало. +func refsIn(d *doc.Document, from, to int) []Ref { + var out []Ref + for n := from; n <= to; n++ { + if d.Fenced(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 проверяет, что каждая ссылка разрешается. Неразрешённый +// идентификатор всегда ошибка: с заглушками на месте снятых правил третьего +// исхода нет — ссылка ведёт либо к правилу, либо к объяснению, почему его +// сняли (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") { + // Префикс потребителя: локальные правила чужого репозитория + // набору не видны и разрешению не подлежат. + continue + } + if s.Manifest.PrefixRetired(ref.Prefix) { + rep.Errorf(Links, d.Path, ref.Line, + "ссылка %s ведёт на выбывший префикс %s", ref.Text, ref.Prefix) + continue + } + target, ok := s.ByPrefix[ref.Prefix] + if !ok { + if _, declared := s.Manifest.PathOf(ref.Prefix); declared { + // Файл объявлен, но не прочитан — о нём уже сказано + // проверкой манифеста, второй раз не повторяем. + continue + } + rep.Errorf(Links, d.Path, ref.Line, + "ссылка %s ведёт на префикс %s, которого в манифесте набора нет", ref.Text, ref.Prefix) + continue + } + rule, ok := ruleByNum(target, ref.Num) + if !ok { + rep.Errorf(Links, d.Path, ref.Line, + "ссылка %s не разрешается: в %s правила с номером %d нет", ref.Text, target.Path, ref.Num) + continue + } + if ref.Sub > 0 && !mentions(target, rule, ref.Text) { + rep.Errorf(Links, d.Path, ref.Line, + "ссылка %s не разрешается: в области %s такой строки нет", ref.Text, rule.ID()) + } + } +} + +// mentions отвечает, встречается ли текст ссылки в области правила. Так +// проверяется номер строки таблицы: сама строка его и несёт. +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 +} diff --git a/internal/check/report.go b/internal/check/report.go new file mode 100644 index 0000000..a79eaa8 --- /dev/null +++ b/internal/check/report.go @@ -0,0 +1,106 @@ +// Package check прогоняет проверки целостности набора. +// +// Деление на семейства взято из языка и сохранено в коде: форма правила +// проверяется в любом файле, который язык употребляет; распространение — только +// в файлах конвенций, потому что эти проверки о том, что документ уезжает к +// потребителю. Третья часть списка — взаимоисключительность строк таблицы, +// покрытие области действия, самодостаточность нормы — сюда не входит: она не +// даётся разбором текста и остаётся работой читателя. +package check + +import ( + "fmt" + "sort" +) + +// Severity различает ошибку и предупреждение. Ошибка — нарушение, названное +// правилом набора; предупреждение — то, что стоит посмотреть глазами. +type Severity int + +const ( + Error Severity = iota + 1 + Warning +) + +func (s Severity) String() string { + if s == Warning { + return "предупреждение" + } + return "ошибка" +} + +// Family — семейство проверок, из которого пришла находка. +type Family string + +const ( + Manifest Family = "манифест" + Form Family = "форма" + Spread Family = "распространение" + Links Family = "ссылки" +) + +// Finding — одна находка. +type Finding struct { + Severity Severity + Family Family + // Path — путь файла от корня набора; пусто, если находка о наборе целиком. + Path string + // Line — строка файла; ноль, если находка не привязана к строке. + Line int + Msg string +} + +// Report накапливает находки одного прогона. +type Report struct { + findings []Finding +} + +// Errorf записывает ошибку. +func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) { + r.add(Error, f, path, line, format, args...) +} + +// Warnf записывает предупреждение. +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 отдаёт находки в порядке файла и строки. Находки о наборе целиком +// идут первыми: пока манифест не сходится, остальное читать рано. +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 считает находки уровня ошибки. +func (r *Report) Errors() int { + n := 0 + for _, f := range r.findings { + if f.Severity == Error { + n++ + } + } + return n +} + +// Warnings считает предупреждения. +func (r *Report) Warnings() int { + return len(r.findings) - r.Errors() +} diff --git a/internal/check/spread.go b/internal/check/spread.go new file mode 100644 index 0000000..7f30c54 --- /dev/null +++ b/internal/check/spread.go @@ -0,0 +1,180 @@ +package check + +import ( + "path" + "regexp" + "strings" + + "git.vakhrushev.me/av/convy/internal/doc" + "git.vakhrushev.me/av/convy/internal/lang" + "git.vakhrushev.me/av/convy/internal/suite" +) + +// checkSpread проверяет то, что относится к отъезду документа к потребителю. +// Применяется только к файлам конвенций: документ, которым набор ведёт себя +// сам, не уезжает никуда, и путь канона в нём законен. +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) + checkForeignTopicPrefix(s, d, rep) +} + +// checkTopic сверяет тему из шапки с манифестом (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, + "тема %q значится среди выбывших: снятое имя другой теме не выдаётся", topic) + case !s.Manifest.TopicLive(topic): + rep.Errorf(Spread, d.Path, at, + "тема %q не объявлена в манифесте набора", topic) + } +} + +// checkAxis сверяет объявленную ось с путём файла. Ось объявляется в шапке, а +// не выводится из пути (META-38); но если директории осей используются, +// расхождение означает переезд файла без правки шапки. +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, + "путь кладёт файл на ось %s=%s, а шапка объявляет %s=%q", key, parts[i+1], key, declared) + } +} + +// checkExtends проверяет, что объявленная база существует и принадлежит той же +// теме. Ключ документирует связь слоёв для человека — документация, которая +// врёт, хуже отсутствующей. +func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) { + if d.Front.Extends == "" { + return + } + at := d.Front.At["extends"] + target := resolveExtends(s, d.Front.Extends) + if target == nil { + rep.Errorf(Spread, d.Path, at, + "extends указывает на %q, а такого файла в наборе нет", d.Front.Extends) + return + } + if target.Front.Topic != d.Front.Topic { + rep.Errorf(Spread, d.Path, at, + "extends указывает на %q с темой %q, а файл несёт тему %q: слои одной темы объявляют одно имя", + d.Front.Extends, target.Front.Topic, d.Front.Topic) + } + if target.Front.Axis() { + rep.Errorf(Spread, d.Path, at, + "extends указывает на %q, а это не базовый слой: у него объявлена ось", d.Front.Extends) + } +} + +// resolveExtends ищет документ по пути, записанному в extends. Путь даётся от +// директории конвенций, поэтому пробуем и его, и путь от корня набора. +func resolveExtends(s *suite.Suite, ref string) *doc.Document { + ref = path.Clean(strings.TrimPrefix(ref, "./")) + for _, d := range s.Docs { + if d.Path == ref || strings.HasSuffix(d.Path, "/"+ref) { + return d + } + } + return nil +} + +// checkMechanized ищет метку механизации в тексте конвенции. Механизирована +// норма или нет — свойство репозитория, а не набора, поэтому место отметки — +// локальная часть копии (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, + "метка %s стоит в тексте конвенции: её место — запись о механизации в локальной части копии", word) + } + return true + }) +} + +// mdPathRe ловит то, что выглядит путём к файлу набора. +var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`) + +// checkCanonPaths ищет путь файла канона в тексте конвенции (META-21). В +// репозитории потребителя конвенция лежит собранной, слои одной темы — секции +// одного файла, и путь `lang/go/logging.md` там не существует: ссылка на него +// умирает при сборке, причём молча — текст остаётся связным. +// +// Инлайн-код здесь не вырезается: путь в бэктиках — тоже путь. +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) { + target := resolveExtends(s, candidate) + if target == nil && !s.Exists(candidate) { + continue + } + rep.Errorf(Spread, d.Path, n, + "в тексте стоит путь файла канона %q: ссылаются именем темы или идентификатором правила", candidate) + } + } +} + +// checkForeignTopicPrefix ищет префикс чужой темы в блоке нормы (META-20). +// Норму правила можно исполнить, имея один этот файл: репозиторий подписывается +// на произвольное подмножество конвенций, и графа зависимостей у него нет. +// Префикс базового слоя своей темы там допустим (META-24) — собранный файл +// начинается с него независимо от выбранных языка и стека. +func checkForeignTopicPrefix(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 { + continue + } + if target.Front.Topic != d.Front.Topic { + rep.Errorf(Spread, d.Path, ref.Line, + "норма %s ссылается на %s из чужой темы %q: наружу смотрит только обоснование", + r.ID(), ref.Text, target.Front.Topic) + continue + } + if target.Front.Axis() { + rep.Errorf(Spread, d.Path, ref.Line, + "норма %s ссылается на %s — слой своей темы, но не базовый: в копию он попадает по манифесту, и гарантии, что он рядом, нет", + r.ID(), ref.Text) + } + } + } + } +} diff --git a/internal/check/suite.go b/internal/check/suite.go new file mode 100644 index 0000000..a3af5f7 --- /dev/null +++ b/internal/check/suite.go @@ -0,0 +1,118 @@ +package check + +import ( + "sort" + + "git.vakhrushev.me/av/convy/internal/manifest" + "git.vakhrushev.me/av/convy/internal/suite" +) + +// Suite прогоняет все проверки набора и возвращает отчёт. +func Suite(s *suite.Suite) *Report { + rep := &Report{} + checkManifest(s, rep) + checkBaseLayers(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 проверяет сам манифест: форму префиксов, непересечение живого +// с выбывшим, наличие объявленных файлов и отсутствие незарегистрированных. +func checkManifest(s *suite.Suite, rep *Report) { + m := s.Manifest + + for _, key := range m.Undecoded { + rep.Warnf(Manifest, manifest.Name, 0, "ключ %s инструменту неизвестен", key) + } + for _, name := range []string{m.Language.Description, m.Language.Reading} { + if name != "" && !s.Exists(name) { + rep.Errorf(Manifest, manifest.Name, 0, "секция [language] объявляет документ %q, а файла нет", 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, + "префикс %s значится и среди живых, и среди выбывших: выбывший не выдаётся повторно", 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, + "на файл %q объявлено несколько префиксов (%v): префикс принадлежит файлу", path, prefixes) + } + } + for prefix := range m.Prefixes.Retired { + if err := manifest.ValidPrefix(prefix); err != nil { + rep.Errorf(Manifest, manifest.Name, 0, "среди выбывших: %s", err) + } + } + + for _, prefix := range sortedKeys(s.Missing) { + rep.Errorf(Manifest, manifest.Name, 0, + "префикс %s объявлен за файлом %q, а файла в наборе нет", prefix, s.Missing[prefix]) + } + for _, path := range s.Unregistered { + rep.Errorf(Manifest, path, 1, + "файл записан языком конвенций, но в манифесте набора не объявлен: для набора его нет") + } + 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, + "тема %q значится и среди живых, и среди выбывших", topic) + } + if len(s.Layers(topic)) == 0 { + rep.Errorf(Manifest, manifest.Name, 0, + "тема %q объявлена живой, а слоёв у неё в наборе нет: тема живёт, пока есть хотя бы один слой", topic) + } + } +} + +// checkBaseLayers проверяет, что у темы не больше одного слоя без ключей оси. +// Базовый слой единственный: он попадает в копию всегда, и второй такой +// означал бы два базовых текста в одном собранном файле. +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, + "у темы %q больше одного слоя без ключей оси: базовый слой единственный, остальные — %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 +} diff --git a/internal/check/text.go b/internal/check/text.go new file mode 100644 index 0000000..ddcda55 --- /dev/null +++ b/internal/check/text.go @@ -0,0 +1,41 @@ +package check + +import ( + "unicode" + "unicode/utf8" +) + +// letterAt отвечает, стоит ли по смещению i буква или цифра. Кириллица в UTF-8 +// занимает два байта, поэтому решать по одному байту нельзя. +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 отвечает, стоит ли перед смещением 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) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..4161718 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,104 @@ +// Package cli раскладывает команды инструмента. +// +// Глубина команды отражает частоту и адресата: проектные команды выполняются в +// каждом репозитории и часто, ведение набора — в одном репозитории и редко. +// Поэтому `check` стоит наверху, а под `suite` уходит то, что в проекте не +// имеет смысла. Синонимов нет: `convy suite pull` рядом с `convy pull` не +// заводится. +package cli + +import ( + "fmt" + "io" + "os" +) + +// ExitCode — код возврата процесса. +type ExitCode int + +const ( + // OK — проверка прошла, работа сделана. + OK ExitCode = 0 + // Failed — проверка нашла ошибки. + Failed ExitCode = 1 + // Usage — команда набрана неверно или не в том контексте. + Usage ExitCode = 2 +) + +// Env — окружение запуска. Вынесено, чтобы команды тестировались без процесса. +type Env struct { + Dir string + Out io.Writer + Err io.Writer + NoTTY bool + Colors bool +} + +// Run разбирает аргументы и выполняет команду. +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, "команда %q ещё не реализована\n", args[0]) + return Usage + case "help", "-h", "--help": + usage(env.Out) + return OK + default: + fmt.Fprintf(env.Err, "неизвестная команда %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: нужна подкоманда — check") + return Usage + } + switch args[0] { + case "check": + return runSuiteCheck(env, args[1:]) + case "new": + fmt.Fprintln(env.Err, "команда \"suite new\" ещё не реализована") + return Usage + default: + fmt.Fprintf(env.Err, "неизвестная подкоманда %q для convy suite\n", args[0]) + return Usage + } +} + +// usage печатает помощь, сгруппированную заголовками: в плоском списке уровни +// не видны. +func usage(w io.Writer) { + fmt.Fprint(w, `convy — управление конвенциями разработки. + +В проекте: + convy add <тема> подписаться и собрать (не реализовано) + convy pull пересобрать подписанное (не реализовано) + convy list что подключено и что доступно (не реализовано) + convy check проверить форму того, что здесь (не реализовано) + +В наборе: + convy suite check целостность набора: префиксы, темы, оси, ссылки, форма + convy suite new новая тема (не реализовано) + +`) +} + +// Main — точка входа процесса. +func Main() int { + dir, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, "не удалось определить текущую директорию:", err) + return int(Usage) + } + env := Env{Dir: dir, Out: os.Stdout, Err: os.Stderr} + return int(Run(env, os.Args[1:])) +} diff --git a/internal/cli/suitecheck.go b/internal/cli/suitecheck.go new file mode 100644 index 0000000..a6d9956 --- /dev/null +++ b/internal/cli/suitecheck.go @@ -0,0 +1,97 @@ +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", "", "корень набора; по умолчанию ищется вверх от текущей директории") + quiet := fs.Bool("quiet", false, "печатать только находки") + 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, "здесь не набор конвенций: рядом и выше нет %s\n", manifest.Name) + if _, err := os.Stat(filepath.Join(env.Dir, ".conventions.toml")); err == nil { + fmt.Fprintln(env.Err, "это проект — проверка того, что здесь, называется \"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", label(f.Severity), where, f.Msg, f.Family) + } + + if quiet { + return + } + if len(findings) > 0 { + fmt.Fprintln(w) + } + fmt.Fprintf(w, "набор: %d файлов, %d тем, версия языка %d (%s)\n", + len(s.Docs), len(s.Manifest.LiveTopics()), s.Manifest.Language.Version, s.Manifest.Language.Lang) + switch { + case rep.Errors() > 0: + fmt.Fprintf(w, "ошибок: %d, предупреждений: %d\n", rep.Errors(), rep.Warnings()) + case rep.Warnings() > 0: + fmt.Fprintf(w, "ошибок нет, предупреждений: %d\n", rep.Warnings()) + default: + fmt.Fprintln(w, "целостность набора в порядке") + } +} + +func label(s check.Severity) string { + if s == check.Warning { + return "предупреждение" + } + return "ошибка" +} diff --git a/internal/doc/doc.go b/internal/doc/doc.go new file mode 100644 index 0000000..0eae939 --- /dev/null +++ b/internal/doc/doc.go @@ -0,0 +1,376 @@ +// Package doc разбирает файл, записанный языком конвенций: шапку, правила и +// их блоки. +// +// Модель разбора взята прямо из языка. Правило — заголовок вида +// `### <ПРЕФИКС>-<номер>. <название>`; область правила тянется от заголовка до +// следующего заголовка любого уровня. Внутри области текст принадлежит +// последнему открытому блоку: метка блок открывает, и блок длится до следующей +// метки или до конца области. Проза — то, что лежит вне областей правил. +// +// Границу считает разметка, а не суждение о том, где правило кончилось: ровно +// поэтому проверка «модальных слов вне правил нет» вообще реализуема. +package doc + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" + + "git.vakhrushev.me/av/convy/internal/lang" +) + +// Heading — заголовок любого уровня. +type Heading struct { + Level int + Text string + Line int +} + +// BlockKind различает блок нормы и блок под меткой. +type BlockKind int + +const ( + // Norm — блок нормы: открыт модальным словом. + Norm BlockKind = iota + 1 + // Marked — блок под меткой: ПОЧЕМУ, ПРИМЕРЫ, СНЯТО, МЕХАНИЗИРОВАНО. + Marked +) + +// Block — часть правила, открытая словом словаря в начале абзаца. +type Block struct { + Kind BlockKind + Word string + Level lang.Level + Mark lang.Mark + // Start — строка, на которой стоит открывающая метка. + Start int + // End — последняя строка блока: блок длится до следующей метки или до + // конца области правила. + End int + // Rest — текст абзаца после метки. + Rest string +} + +// Rule — правило: заголовок с идентификатором и его область. +type Rule struct { + Prefix string + Num int + Title string + // Line — строка заголовка. + Line int + // HeadingLevel — уровень заголовка; каноническая форма — третий. + HeadingLevel int + // Malformed — заголовок опознан как правило, но записан не по форме + // `### <ПРЕФИКС>-<номер>. <название>`. + Malformed string + // Start, End — область правила: от строки после заголовка до строки + // перед следующим заголовком включительно. + Start, End int + Blocks []Block +} + +// ID возвращает идентификатор правила. +func (r Rule) ID() string { + return fmt.Sprintf("%s-%d", r.Prefix, r.Num) +} + +// Block ищет первый блок под указанной меткой. +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 перечисляет блоки нормы. Их должно быть ровно ноль (у снятого +// правила) или один: норма — одна фраза, две нормы под одним номером нечем +// адресовать по отдельности. +func (r Rule) Norms() []Block { + var out []Block + for _, b := range r.Blocks { + if b.Kind == Norm { + out = append(out, b) + } + } + return out +} + +// Paragraph — абзац: строки между пустыми. +type Paragraph struct { + Start, End int + Lines []string +} + +// Text склеивает абзац в одну строку. +func (p Paragraph) Text() string { + return strings.Join(p.Lines, " ") +} + +// Document — разобранный файл. +type Document struct { + // Path — путь от корня набора, в форме со слэшами. + Path string + Front Front + lines []string + fence []bool + // Body — первая строка тела, после шапки. + Body int + Headings []Heading + Rules []Rule +} + +var ( + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) + // ruleHeadRe ловит заголовок, начинающийся с идентификатора правила, — + // в том числе записанный не по форме: иначе опечатка в заголовке + // превратила бы правило в прозу и молча исчезла из нумерации. + ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`) + fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})") +) + +// Load читает и разбирает файл. path — путь от корня набора, name — путь в +// файловой системе. +func Load(path, name string) (*Document, error) { + data, err := os.ReadFile(name) + if err != nil { + return nil, err + } + return Parse(path, string(data)) +} + +// Parse разбирает содержимое файла. +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: шапка: %w", path, err) + } + + d.markFences() + d.collectHeadings() + d.collectRules() + return d, nil +} + +// markFences отмечает строки внутри огороженных блоков кода. Всё, что +// проверяется разбором текста, эти строки пропускает: пример на SQL с +// заглавным WHEN не делает набор двуязычным, а `### XKEY-5` из примера в +// документации — не правило. +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.lineCount(), + } + 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 = "у заголовка правила нет названия" + case strings.HasPrefix(tail, "."): + rule.Title = strings.TrimSpace(tail[1:]) + default: + rule.Malformed = "после идентификатора в заголовке нет точки" + rule.Title = strings.TrimSpace(tail) + } + d.Rules = append(d.Rules, rule) + } +} + +// Blocks размечает области правил по словарю набора. Разметка отложена до +// загрузки манифеста: до неё неизвестно, каким словарём записан набор. +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 выделяет содержимое первого полужирного участка, если абзац с него +// начинается. Метка стоит первой в своём абзаце, полужирным и с точкой — +// именно этим она отличается от упоминания ступени в середине фразы. +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 режет диапазон строк на абзацы. Границы включительные, нумерация +// с единицы. Строки внутри огороженных блоков в абзацы не попадают: код — +// иллюстрация, а не текст правила. +func (d *Document) Paragraphs(from, to int) []Paragraph { + var out []Paragraph + var cur *Paragraph + for n := max(from, 1); n <= min(to, d.lineCount()); 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 возвращает границы вводной прозы: от тела до первого правила. +func (d *Document) Preamble() (from, to int) { + if len(d.Rules) == 0 { + return d.Body, d.lineCount() + } + return d.Body, d.Rules[0].Line - 1 +} + +// InRule отвечает, лежит ли строка внутри области какого-нибудь правила. +func (d *Document) InRule(n int) bool { + for _, r := range d.Rules { + if n >= r.Line && n <= r.End { + return true + } + } + return false +} + +// Line возвращает строку с номером n. +func (d *Document) Line(n int) string { + if n < 1 || n > d.lineCount() { + return "" + } + return d.lines[n-1] +} + +// Fenced отвечает, лежит ли строка внутри огороженного блока кода. +func (d *Document) Fenced(n int) bool { + return n >= 1 && n <= d.lineCount() && d.fence[n-1] +} + +// Prose проходит строки тела, не попавшие в огороженные блоки, и отдаёт их с +// вырезанным содержимым инлайн-кода. В бэктиках идентификатор стоит как +// пример записи, а не как ссылка, — различает их именно разметка. +func (d *Document) Prose(yield func(n int, text string) bool) { + for n := d.Body; n <= d.lineCount(); n++ { + if d.fence[n-1] { + continue + } + if !yield(n, StripInline(d.lines[n-1])) { + return + } + } +} + +// StripInline вырезает содержимое инлайн-кода, оставляя разделители. +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 возвращает число строк в файле. +func (d *Document) Len() int { return len(d.lines) } + +func (d *Document) lineCount() int { return len(d.lines) } diff --git a/internal/doc/front.go b/internal/doc/front.go new file mode 100644 index 0000000..7027955 --- /dev/null +++ b/internal/doc/front.go @@ -0,0 +1,80 @@ +package doc + +import ( + "fmt" + "strings" +) + +// Front — шапка файла. Ключей немного и все они плоские, поэтому разбор здесь +// свой: тащить YAML ради четырёх строк `ключ: значение` не за что. +// +// Ось слоя объявляется здесь ключами lang и stack, а не выводится из пути +// (META-38); отсутствие обоих означает базовый слой темы. +type Front struct { + Topic string + Prefix string + Lang string + Stack string + Extends string + + // At — номер строки, на которой объявлен ключ; нужен, чтобы находка + // показывала на объявление, а не на начало файла. + At map[string]int + // Unknown — ключи, которых модель не знает. + Unknown []string + // End — номер строки закрывающего разделителя. Тело файла начинается + // со следующей. + End int + // Present — была ли шапка вообще. + Present bool +} + +// Axis отвечает, объявлена ли у слоя ось. Слой без ключей оси — базовый. +func (f Front) Axis() bool { + return f.Lang != "" || f.Stack != "" +} + +// parseFront разбирает шапку из строк файла. Возвращает шапку и номер первой +// строки тела. +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("строка %d шапки не имеет вида «ключ: значение»", 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("шапка не закрыта разделителем ---") +} diff --git a/internal/lang/vocabulary.go b/internal/lang/vocabulary.go new file mode 100644 index 0000000..38b52da --- /dev/null +++ b/internal/lang/vocabulary.go @@ -0,0 +1,281 @@ +// Package lang держит словари языка конвенций: слова, которыми записаны +// модальность правила, метки его блоков и связки сценарного блока. +// +// Словарь — свойство версии языка и естественного языка набора, а не самого +// набора: версия 1 по-русски задаёт один и тот же список слов в любом +// репозитории, и повторять его в каждом манифесте незачем. Пока спецификация +// языка живёт вместе с каноном, словари лежат здесь; когда она уедет в +// отдельный репозиторий со своими файлами словарей, источником станут они, а +// форма Vocabulary и все проверки поверх неё останутся прежними. +package lang + +import ( + "fmt" + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +// Level — ступень шкалы обязательности. Ступеней пять в четырёх категориях +// ISO/IEC Directives, Part 2; какими словами они названы — параметр +// естественного языка, а сама шкала одна на все словари. +type Level int + +const ( + Requirement Level = iota + 1 + Prohibition + Recommendation + RecommendationAgainst + Permission +) + +// String даёт имя ступени для сообщений об ошибках — не слово словаря, а роль. +func (l Level) String() string { + switch l { + case Requirement: + return "требование" + case Prohibition: + return "запрет" + case Recommendation: + return "рекомендация" + case RecommendationAgainst: + return "рекомендация против" + case Permission: + return "разрешение" + } + return "неизвестная ступень" +} + +// Mark — метка блока правила. Метки обязательности не задают, а размечают: +// что здесь обоснование, что иллюстрация, что запись о механизации, что +// заглушка на месте снятого правила. +type Mark int + +const ( + Rationale Mark = iota + 1 + Examples + Mechanized + Retired +) + +func (m Mark) String() string { + switch m { + case Rationale: + return "обоснование" + case Examples: + return "примеры" + case Mechanized: + return "механизация" + case Retired: + return "снятое правило" + } + return "неизвестная метка" +} + +// Connective — служебное слово сценарного блока. В строку о версии языка эти +// слова не входят и под проверку «модальные слова вне правил» не подпадают: +// обязательности они не задают, только структуру. +type Connective int + +const ( + When Connective = iota + 1 + Then + And + Or +) + +// Vocabulary — словарь одной версии языка на одном естественном языке. +type Vocabulary struct { + Version int + Code string + Modals map[string]Level + Marks map[string]Mark + Scenario map[string]Connective +} + +// Modal сообщает ступень слова, если слово принадлежит шкале этого словаря. +func (v Vocabulary) Modal(word string) (Level, bool) { + l, ok := v.Modals[word] + return l, ok +} + +// Mark сообщает роль метки, если слово принадлежит меткам этого словаря. +func (v Vocabulary) Mark(word string) (Mark, bool) { + m, ok := v.Marks[word] + return m, ok +} + +// Word возвращает слово, которым в этом словаре записана ступень. +func (v Vocabulary) Word(l Level) string { + for w, got := range v.Modals { + if got == l { + return w + } + } + return "" +} + +// MarkWord возвращает слово, которым в этом словаре записана метка. +func (v Vocabulary) MarkWord(m Mark) string { + for w, got := range v.Marks { + if got == m { + return w + } + } + return "" +} + +// Lead возвращает слово словаря, которым начинается text, и его длину. +// Длиннейшее совпадение выигрывает: «НЕ ДОЛЖЕН» не должен читаться как +// «ДОЛЖЕН», а метка с датой («СНЯТО 2026-07-26») — как метка без неё. +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 перечисляет все слова словаря, которые язык объявляет в строке о +// версии: ступени шкалы и метки. Связки сценария сюда не входят. +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 проверяет, что text начинается со слова w и слово на этом +// кончается: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не является. +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 — словари, известные бинарю. Ключ верхнего уровня — версия языка, +// вложенный — код естественного языка набора. +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, + }, + }, + "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, + }, + }, + }, +} + +// Lookup выдаёт словарь версии языка на указанном естественном языке. +func Lookup(version int, code string) (Vocabulary, error) { + byCode, ok := registry[version] + if !ok { + return Vocabulary{}, fmt.Errorf("версия языка %d инструменту неизвестна, известны: %s", version, versions()) + } + v, ok := byCode[code] + if !ok { + return Vocabulary{}, fmt.Errorf("словарь %q для версии языка %d инструменту неизвестен, известны: %s", code, version, codes(version)) + } + return v, nil +} + +// Foreign перечисляет слова чужих словарей той же версии — те, по которым +// видно смесь словарей. Слова, совпадающие с собственными, отброшены. +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 +} + +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, ", ") +} diff --git a/internal/lang/vocabulary_test.go b/internal/lang/vocabulary_test.go new file mode 100644 index 0000000..90172e2 --- /dev/null +++ b/internal/lang/vocabulary_test.go @@ -0,0 +1,73 @@ +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, ждали, что слово не найдётся", tc.text, got) + } + continue + } + if !ok || got != tc.want { + t.Errorf("Lead(%q) = %q, %v; ждали %q", tc.text, got, ok, tc.want) + } + } +} + +func TestForeignExcludesOwnWords(t *testing.T) { + foreign := lang.Foreign(1, "ru") + if len(foreign) == 0 { + t.Fatal("для русского словаря не нашлось ни одного чужого слова") + } + if code, ok := foreign["MUST"]; !ok || code != "en" { + t.Errorf("MUST должно опознаваться как слово словаря en, получили %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("слово %q собственное, а попало в чужие", word) + } + if _, own := v.Mark(word); own { + t.Errorf("метка %q собственная, а попала в чужие", word) + } + } +} + +func TestUnknownVersionAndCode(t *testing.T) { + if _, err := lang.Lookup(99, "ru"); err == nil { + t.Error("неизвестная версия языка принята без ошибки") + } + if _, err := lang.Lookup(1, "xx"); err == nil { + t.Error("неизвестный словарь принят без ошибки") + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..2057d5e --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,178 @@ +// Package manifest читает suite.toml — манифест набора конвенций. +// +// Манифест объявляет три вещи: язык, которым записаны правила набора, живые и +// выбывшие темы, живые и выбывшие префиксы правил вместе с путями к файлам. +// Инструмент не знает ни одной темы и ни одного префикса заранее — весь этот +// список приходит отсюда. +package manifest + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// Name — имя манифеста набора. По тому, какой из двух манифестов лежит рядом, +// определяется контекст: suite.toml — набор, .conventions.toml — проект. +const Name = "suite.toml" + +// DefaultLanguageCode — естественный язык набора, когда манифест о нём молчит. +// Ключ необязателен намеренно: словарь живёт в бинаре, и заставлять каждый +// набор объявлять то, что и так подразумевается, незачем. +const DefaultLanguageCode = "ru" + +// Language — секция [language]: версия языка конвенций и два документа о нём. +// Полное описание остаётся у автора набора, короткое едет в копию. +type Language struct { + Version int `toml:"version"` + Lang string `toml:"lang"` + Description string `toml:"description"` + Reading string `toml:"reading"` +} + +// Section — раздел манифеста, разбитый на живую и выбывшую части. Выбывшее +// хранится, а не удаляется: имя темы и префикс правила живут в чужих +// репозиториях, и переиспользовать их нельзя никогда. +type Section struct { + Live map[string]string `toml:"live"` + Retired map[string]string `toml:"retired"` +} + +// Manifest — разобранный suite.toml. +type Manifest struct { + Language Language `toml:"language"` + Topics Section `toml:"topics"` + Prefixes Section `toml:"prefixes"` + + // Path — путь, по которому манифест прочитан. + Path string `toml:"-"` + // Undecoded — ключи, которых инструмент не знает. Опечатка в манифесте + // иначе прошла бы молча, а стоит она подписки или целого файла. + Undecoded []string `toml:"-"` +} + +// Load читает манифест набора из директории root. +func Load(root string) (*Manifest, error) { + path := filepath.Join(root, Name) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("чтение манифеста набора: %w", err) + } + + var m Manifest + meta, err := toml.Decode(string(data), &m) + if err != nil { + return nil, fmt.Errorf("разбор %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 поднимается от start вверх до корня, ища директорию с манифестом +// набора. Так `convy suite check` работает из любой поддиректории набора. +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 означает, что рядом и выше нет манифеста набора. +var ErrNotFound = errors.New("манифест набора не найден") + +// LivePrefixes перечисляет живые префиксы в порядке, устойчивом между +// запусками: вывод проверки не должен зависеть от обхода карты. +func (m *Manifest) LivePrefixes() []string { + return sortedKeys(m.Prefixes.Live) +} + +// LiveTopics перечисляет живые темы в устойчивом порядке. +func (m *Manifest) LiveTopics() []string { + return sortedKeys(m.Topics.Live) +} + +// PrefixOf возвращает префикс, объявленный за файлом, если такой есть. +// Путь сверяется в форме со слэшами — так он записан в манифесте. +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 возвращает путь, объявленный за живым префиксом. +func (m *Manifest) PathOf(prefix string) (string, bool) { + path, ok := m.Prefixes.Live[prefix] + return path, ok +} + +// TopicLive отвечает, объявлена ли тема среди живых. +func (m *Manifest) TopicLive(topic string) bool { + _, ok := m.Topics.Live[topic] + return ok +} + +// TopicRetired отвечает, значится ли тема среди выбывших. +func (m *Manifest) TopicRetired(topic string) bool { + _, ok := m.Topics.Retired[topic] + return ok +} + +// PrefixRetired отвечает, значится ли префикс среди выбывших. +func (m *Manifest) PrefixRetired(prefix string) bool { + _, ok := m.Prefixes.Retired[prefix] + return ok +} + +// ValidPrefix проверяет форму префикса: четыре заглавные латинские буквы. +// Буква X в начале зарезервирована за репозиториями-потребителями, и набор +// её не занимает никогда. +func ValidPrefix(prefix string) error { + if len(prefix) != 4 { + return fmt.Errorf("префикс %q — не четыре буквы", prefix) + } + for _, r := range prefix { + if r < 'A' || r > 'Z' { + return fmt.Errorf("префикс %q содержит не заглавную латинскую букву", prefix) + } + } + if strings.HasPrefix(prefix, "X") { + return fmt.Errorf("префикс %q начинается на X — буква зарезервирована за локальными правилами потребителей", 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 +} diff --git a/internal/suite/suite.go b/internal/suite/suite.go new file mode 100644 index 0000000..df96573 --- /dev/null +++ b/internal/suite/suite.go @@ -0,0 +1,173 @@ +// Package suite собирает набор конвенций в память: манифест, словарь языка и +// документы, которые язык употребляет. +// +// Проверке подлежит всё, что язык употребляет: файлы конвенций и документ, +// которым набор ведёт себя сам. Список этих файлов даёт манифест — раздел +// живых префиксов, где у каждого префикса записан путь. Файл, который язык +// только цитирует (описание языка), в наборе не значится и проверок не +// получает. +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 — загруженный набор. +type Suite struct { + Root string + Manifest *manifest.Manifest + Vocab lang.Vocabulary + + // Docs — документы набора в порядке живых префиксов. + Docs []*doc.Document + // ByPrefix — документ по префиксу из манифеста. + ByPrefix map[string]*doc.Document + // Missing — префиксы, чей файл манифест объявляет, а файловой системы в + // нём нет. + Missing map[string]string + // Unregistered — найденные в наборе файлы с шапкой, которых манифест не + // объявляет. Такой файл не проверяется и не собирается: для набора его + // нет, хотя автор считает иначе. + Unregistered []string + // Broken — файлы, чью шапку не удалось разобрать. + Broken []error +} + +// Load читает набор из директории 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 обходит набор и ищет файлы, записанные языком конвенций, но +// не объявленные в манифесте. Признак — ключ prefix в шапке, а не +// расположение файла: таксономию набор перестраивает, а шапка утверждает. +func (s *Suite) findUnregistered() error { + declared := make(map[string]bool) + for _, path := range s.Manifest.Prefixes.Live { + declared[filepath.ToSlash(path)] = true + } + for _, path := range s.Manifest.Prefixes.Retired { + 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 { + // Шапки у файла нет или она сломана — для набора это не + // документ языка, а просто markdown рядом. + return nil + } + if d.Front.Prefix != "" { + s.Unregistered = append(s.Unregistered, rel) + } + return nil + }) + if err != nil { + return fmt.Errorf("обход набора: %w", err) + } + sort.Strings(s.Unregistered) + return nil +} + +// Conventions отбирает документы конвенций — те, что несут тему и потому +// уезжают к потребителю. Документ без темы (набор ведёт им себя сам) проверки +// распространения не получает. +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 перечисляет слои темы — документы, объявившие это имя в шапке. +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) + } + } + return out +} + +// Prefix возвращает префикс, объявленный манифестом за документом. +func (s *Suite) Prefix(d *doc.Document) string { + prefix, _ := s.Manifest.PrefixOf(d.Path) + return prefix +} + +// Exists проверяет, есть ли в наборе файл по пути от корня. +func (s *Suite) Exists(rel string) bool { + _, err := os.Stat(filepath.Join(s.Root, filepath.FromSlash(rel))) + return err == nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9743e61 --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +// Команда convy — управление конвенциями разработки: проверка целостности +// набора и сборка копий в проектах. +package main + +import ( + "os" + + "git.vakhrushev.me/av/convy/internal/cli" +) + +func main() { + os.Exit(cli.Main()) +}