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)) } }) } }