Files
av 600aba5ee3 закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
2026-07-28 10:16:27 +03:00

294 lines
11 KiB
Go

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{
".conventions-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[".conventions-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[".conventions-suite.toml"] = strings.Replace(layeredManifest,
`SLOG = "conventions/arch/logging.md"`,
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
},
}}
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))
}
})
}
}