- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными ссылками отклоняется с перечнем мест - добавлена проверка META-30: короткое описание языка обязано называть все слова словаря, иначе читатель копии толкует их по памяти - retired-раздел манифеста больше не считается объявлением пути: снятый префикс означает, что файл ушёл вместе с ним
293 lines
10 KiB
Go
293 lines
10 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{
|
|
"suite.toml": layeredManifest,
|
|
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
|
"READING.md": reading,
|
|
"conventions/arch/time.md": archTime,
|
|
"conventions/lang/go/time.md": goTime,
|
|
"conventions/arch/logging.md": archLogging,
|
|
}
|
|
}
|
|
|
|
func TestLayeredSuiteIsClean(t *testing.T) {
|
|
if got := run(t, layered()); len(got) != 0 {
|
|
t.Fatalf("a sound layered suite produced findings:\n%s", messages(got))
|
|
}
|
|
}
|
|
|
|
func TestLayeredChecks(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setup func(files)
|
|
want string
|
|
}{{
|
|
name: "axis in the front matter diverges from the path",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
|
|
},
|
|
want: `the path puts the file on axis lang=go, while the front matter declares lang="python"`,
|
|
}, {
|
|
name: "extends leads into a foreign topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"extends: arch/time.md", "extends: arch/logging.md", 1)
|
|
},
|
|
want: `with topic "logging", while the file carries topic "time"`,
|
|
}, {
|
|
name: "extends leads into a file that does not exist",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"extends: arch/time.md", "extends: arch/clocks.md", 1)
|
|
},
|
|
want: `and the suite holds no such file`,
|
|
}, {
|
|
name: "topic with two layers lacking axis keys",
|
|
setup: func(f files) {
|
|
f["suite.toml"] = strings.Replace(layeredManifest,
|
|
`GTIM = "conventions/lang/go/time.md"`,
|
|
`GTIM = "conventions/second/time.md"`, 1)
|
|
f["conventions/lang/go/time.md"] = ""
|
|
f["conventions/second/time.md"] = strings.Replace(
|
|
strings.Replace(goTime, "lang: go\n", "", 1),
|
|
"extends: arch/time.md\n", "", 1)
|
|
},
|
|
want: "more than one layer without axis keys",
|
|
}, {
|
|
name: "norm references the prefix of a foreign topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
|
|
},
|
|
want: `refers to SLOG-1 from the foreign topic "logging"`,
|
|
}, {
|
|
name: "norm references a non-base layer of its own topic",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
|
|
},
|
|
want: "points at a layer of this topic that is not the base one",
|
|
}, {
|
|
name: "rationale of the base layer references a layer above it",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**ПОЧЕМУ.** Без явного смещения не видно зоны; в Go это выражает GTIM-1.", 1)
|
|
},
|
|
want: "points at a layer of this topic that is not the base one",
|
|
}, {
|
|
name: "a section outside any rule references a layer above",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = archTime +
|
|
"\n## Связано\n\nРеализация на Go — GTIM-1.\n"
|
|
},
|
|
want: "points at a layer of this topic that is not the base one",
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := layered()
|
|
tc.setup(f)
|
|
got := messages(run(t, f))
|
|
if !strings.Contains(got, tc.want) {
|
|
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNoFalsePositives gathers the cases where a check must stay silent. A
|
|
// false positive costs more here than a miss: a check that goes red on a sound
|
|
// file gets switched off altogether.
|
|
func TestNoFalsePositives(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setup func(files)
|
|
}{{
|
|
name: "an identifier in backticks is a sample of notation, not a reference",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"Без явного смещения",
|
|
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
|
|
},
|
|
}, {
|
|
name: "a modal word inside a fenced code block",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = archTime +
|
|
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
|
|
},
|
|
}, {
|
|
name: "an uppercase SQL keyword in a code sample",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1)
|
|
},
|
|
}, {
|
|
name: "a language layer's norm references the base layer of its own topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
|
|
},
|
|
}, {
|
|
name: "a mention of a step in the rationale is not a second norm",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
|
|
},
|
|
}, {
|
|
name: "a stub of a retired rule with a date and a reason",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = archTime +
|
|
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Ширина следует из TIME-1 и отдельного правила не требует.\n"
|
|
},
|
|
}, {
|
|
name: "a scenario block in the suite's own vocabulary",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = archTime +
|
|
"\n## Стык правил\n\nКОГДА зависимость недоступна И ретраи исчерпаны\nТОГДА запись делается один раз (TIME-1)\n"
|
|
},
|
|
}, {
|
|
name: "an uppercase SQL connective mid-sentence outside a fence",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**ПОЧЕМУ.** Условие `WHERE a AND b OR c` сортировку не спасает.", 1)
|
|
},
|
|
}, {
|
|
name: "a rationale references a foreign topic, which META-20 allows",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.",
|
|
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC; тот же довод стоит за SLOG-1.", 1)
|
|
},
|
|
}, {
|
|
name: "a section outside any rule references a foreign topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = goTime +
|
|
"\n## Связано\n\nКонвенция logging, правило SLOG-1.\n"
|
|
},
|
|
}, {
|
|
name: "a layer references the base layer of its topic outside a norm",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.",
|
|
"**ПОЧЕМУ.** Единая точка даёт гарантированный UTC, чего и требует TIME-1.", 1)
|
|
},
|
|
}, {
|
|
name: "the single document without a topic is the one the suite governs itself by",
|
|
setup: func(f files) {
|
|
f["suite.toml"] = strings.Replace(layeredManifest,
|
|
`SLOG = "conventions/arch/logging.md"`,
|
|
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1)
|
|
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
|
|
},
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := layered()
|
|
tc.setup(f)
|
|
if got := run(t, f); len(got) != 0 {
|
|
t.Fatalf("a check fired where it must not:\n%s", messages(got))
|
|
}
|
|
})
|
|
}
|
|
}
|