suite check: закрыты пробелы в проверках шапки, иерархии и словаря
- документ без темы: ошибка, если такой не один или если он несёт ключи слоя — иначе конвенция, потерявшая topic, молча лишалась всех проверок распространения - иерархия заголовков: один заголовок первого уровня, без перескоков через уровень - имя темы: латиница и пригодность для имени файла — ошибкой, kebab-case — предупреждением - сценарные связки чужого словаря ловятся в начале строки, где их ставит сценарный блок, а не в середине фразы, где это чаще SQL
This commit is contained in:
@@ -323,6 +323,66 @@ func TestChecks(t *testing.T) {
|
|||||||
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
|
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
|
||||||
},
|
},
|
||||||
want: "the text holds the canon file path",
|
want: "the text holds the canon file path",
|
||||||
|
}, {
|
||||||
|
name: "document opens below level one",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "# Время", "## Время", 1)
|
||||||
|
},
|
||||||
|
want: "opens with a level-2 heading",
|
||||||
|
}, {
|
||||||
|
name: "second level-one heading",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = baseTime + "\n# Ещё один заголовок\n\nПроза.\n"
|
||||||
|
},
|
||||||
|
want: "holds a second level-1 heading",
|
||||||
|
}, {
|
||||||
|
name: "heading skips a level",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила", "#### Правила", 1)
|
||||||
|
},
|
||||||
|
want: "skipping a level",
|
||||||
|
}, {
|
||||||
|
name: "scenario block opens with a foreign connective",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = baseTime +
|
||||||
|
"\nWHEN зависимость недоступна\nТОГДА запись ERROR\n"
|
||||||
|
},
|
||||||
|
want: `the scenario block opens with WHEN from the "en" vocabulary`,
|
||||||
|
}, {
|
||||||
|
name: "topic name is not usable as a file name",
|
||||||
|
setup: func(f files) {
|
||||||
|
// A bare non-ASCII key TOML rejects on its own; a quoted one
|
||||||
|
// passes straight through, which is what the check is for.
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest, "time =", `"время" =`, 1)
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: время", 1)
|
||||||
|
},
|
||||||
|
want: "is not usable as a file name",
|
||||||
|
}, {
|
||||||
|
name: "topic name is not lower kebab-case",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest, "time =", "Time_Zone =", 1)
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: Time_Zone", 1)
|
||||||
|
},
|
||||||
|
want: "is not lower kebab-case",
|
||||||
|
}, {
|
||||||
|
name: "a second document without a topic",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest,
|
||||||
|
`TIME = "conventions/time.md"`,
|
||||||
|
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1)
|
||||||
|
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
|
||||||
|
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
|
||||||
|
},
|
||||||
|
want: "more than one document without a topic",
|
||||||
|
}, {
|
||||||
|
name: "document without a topic carries layer keys",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest,
|
||||||
|
`TIME = "conventions/time.md"`,
|
||||||
|
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1)
|
||||||
|
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
|
||||||
|
},
|
||||||
|
want: "carries the keys of a layer",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -18,11 +18,74 @@ import (
|
|||||||
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
|
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
prefix := checkFilePrefix(s, d, rep)
|
prefix := checkFilePrefix(s, d, rep)
|
||||||
checkHeadings(d, prefix, rep)
|
checkHeadings(d, prefix, rep)
|
||||||
|
checkHeadingHierarchy(d, rep)
|
||||||
checkNumbering(d, prefix, rep)
|
checkNumbering(d, prefix, rep)
|
||||||
checkRules(s, d, rep)
|
checkRules(s, d, rep)
|
||||||
versionFrom, versionTo := checkVersionLine(s, d, rep)
|
versionFrom, versionTo := checkVersionLine(s, d, rep)
|
||||||
checkModalsOutside(s, d, versionFrom, versionTo, rep)
|
checkModalsOutside(s, d, versionFrom, versionTo, rep)
|
||||||
checkForeignVocabulary(s, d, rep)
|
checkForeignVocabulary(s, d, rep)
|
||||||
|
checkForeignConnectives(s, d, rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkHeadingHierarchy checks the ladder of headings: one title, and no level
|
||||||
|
// skipped on the way down. A rule area runs from a heading to the next heading
|
||||||
|
// of any level, so a skipped level does not merely look untidy — it moves the
|
||||||
|
// boundary the whole parsing model rests on.
|
||||||
|
func checkHeadingHierarchy(d *doc.Document, rep *Report) {
|
||||||
|
if len(d.Headings) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if first := d.Headings[0]; first.Level != 1 {
|
||||||
|
rep.Errorf(Form, d.Path, first.Line,
|
||||||
|
"the document opens with a level-%d heading, while the title of a document is a level-1 heading", first.Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
titles := 0
|
||||||
|
prev := 0
|
||||||
|
for _, h := range d.Headings {
|
||||||
|
if h.Level == 1 {
|
||||||
|
titles++
|
||||||
|
if titles > 1 {
|
||||||
|
rep.Errorf(Form, d.Path, h.Line,
|
||||||
|
"the document holds a second level-1 heading %q: the title is one, everything below it is a section", h.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prev > 0 && h.Level > prev+1 {
|
||||||
|
rep.Errorf(Form, d.Path, h.Line,
|
||||||
|
"the heading %q jumps from level %d to level %d, skipping a level", h.Text, prev, h.Level)
|
||||||
|
}
|
||||||
|
prev = h.Level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForeignConnectives looks for scenario connectives of a foreign
|
||||||
|
// vocabulary. Only the start of a line counts: that is where a scenario block
|
||||||
|
// puts them, while mid-sentence AND and OR belong to SQL far more often than to
|
||||||
|
// a mixture of vocabularies.
|
||||||
|
func checkForeignConnectives(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
foreign := lang.ForeignConnectives(s.Manifest.Language.Version, s.Manifest.Language.Lang)
|
||||||
|
if len(foreign) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
words := make([]string, 0, len(foreign))
|
||||||
|
for w := range foreign {
|
||||||
|
words = append(words, w)
|
||||||
|
}
|
||||||
|
sort.Strings(words)
|
||||||
|
|
||||||
|
d.Prose(func(n int, text string) bool {
|
||||||
|
text = strings.TrimLeft(text, " \t>-*")
|
||||||
|
for _, w := range words {
|
||||||
|
if !strings.HasPrefix(text, w) || letterAt(text, len(w)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rep.Errorf(Form, d.Path, n,
|
||||||
|
"the scenario block opens with %s from the %q vocabulary, while the suite declares %q",
|
||||||
|
w, foreign[w], s.Manifest.Language.Lang)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkFilePrefix reconciles the prefix in the front matter with the manifest
|
// checkFilePrefix reconciles the prefix in the front matter with the manifest
|
||||||
|
|||||||
@@ -222,6 +222,27 @@ func TestNoFalsePositives(t *testing.T) {
|
|||||||
f["conventions/arch/time.md"] = archTime +
|
f["conventions/arch/time.md"] = archTime +
|
||||||
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n"
|
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-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: "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 {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package check
|
package check
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||||
@@ -11,7 +12,9 @@ import (
|
|||||||
func Suite(s *suite.Suite) *Report {
|
func Suite(s *suite.Suite) *Report {
|
||||||
rep := &Report{}
|
rep := &Report{}
|
||||||
checkManifest(s, rep)
|
checkManifest(s, rep)
|
||||||
|
checkTopicNames(s, rep)
|
||||||
checkBaseLayers(s, rep)
|
checkBaseLayers(s, rep)
|
||||||
|
checkSelfGoverning(s, rep)
|
||||||
|
|
||||||
for _, d := range s.Docs {
|
for _, d := range s.Docs {
|
||||||
checkForm(s, d, rep)
|
checkForm(s, d, rep)
|
||||||
@@ -87,6 +90,64 @@ func checkManifest(s *suite.Suite, rep *Report) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// topicNameRe is the hard bound: a topic name reaches the file system of
|
||||||
|
// a consumer, so it has to be usable as a file name — Latin letters,
|
||||||
|
// digits and the plain separators.
|
||||||
|
topicNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
// kebabRe is the recommended shape, and only a recommendation.
|
||||||
|
kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkTopicNames checks the shape of topic names. A name lands both in a
|
||||||
|
// consumer's file system and in their manifest, which is where the hard bound
|
||||||
|
// comes from; lower kebab-case on top of that is a recommendation and speaks as
|
||||||
|
// a warning.
|
||||||
|
func checkTopicNames(s *suite.Suite, rep *Report) {
|
||||||
|
for _, topic := range s.Manifest.LiveTopics() {
|
||||||
|
switch {
|
||||||
|
case !topicNameRe.MatchString(topic):
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0,
|
||||||
|
"the topic name %q is not usable as a file name: a name is written in Latin letters and travels into the file system of a consumer", topic)
|
||||||
|
case !kebabRe.MatchString(topic):
|
||||||
|
rep.Warnf(Manifest, manifest.Name, 0,
|
||||||
|
"the topic name %q is not lower kebab-case, which is the recommended shape", topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSelfGoverning guards the document without a topic.
|
||||||
|
//
|
||||||
|
// A topic-less document is the one the suite governs itself by: it travels
|
||||||
|
// nowhere and cannot be subscribed to, so it gets no spread checks. Nothing in
|
||||||
|
// the manifest tells it from a convention that lost its topic key, and the
|
||||||
|
// silent loss is the expensive one — the file keeps every check of form while
|
||||||
|
// quietly dropping every check about travelling to a consumer. Two markers make
|
||||||
|
// that loss visible: such a document is one per suite, and it has no layer of
|
||||||
|
// its own, hence neither axis keys nor a base.
|
||||||
|
func checkSelfGoverning(s *suite.Suite, rep *Report) {
|
||||||
|
var topicless []string
|
||||||
|
for _, d := range s.Docs {
|
||||||
|
if d.Front.Topic != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
topicless = append(topicless, d.Path)
|
||||||
|
|
||||||
|
if d.Front.Axis() || d.Front.Extends != "" {
|
||||||
|
rep.Errorf(Manifest, d.Path, d.Front.At["prefix"],
|
||||||
|
"the file declares no topic yet carries the keys of a layer: a document without a topic is the one the suite governs itself by, and it is nobody's layer — the topic key looks lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(topicless) > 1 {
|
||||||
|
sort.Strings(topicless)
|
||||||
|
for _, path := range topicless[1:] {
|
||||||
|
rep.Errorf(Manifest, path, 1,
|
||||||
|
"the suite holds more than one document without a topic (%v): only the one the suite governs itself by may lack a topic, so the rest have lost the key",
|
||||||
|
topicless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkBaseLayers checks that a topic holds no more than one layer without axis
|
// checkBaseLayers checks that a topic holds no more than one layer without axis
|
||||||
// keys. The base layer is the only one of its kind: it reaches every copy, and a
|
// keys. The base layer is the only one of its kind: it reaches every copy, and a
|
||||||
// second such layer would mean two base texts in one assembled file.
|
// second such layer would mean two base texts in one assembled file.
|
||||||
|
|||||||
@@ -267,6 +267,31 @@ func Foreign(version int, code string) map[string]string {
|
|||||||
return foreign
|
return foreign
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForeignConnectives lists the scenario connectives of the other vocabularies
|
||||||
|
// of the same version. They are kept apart from Foreign because they are caught
|
||||||
|
// differently: a connective is a whole word of everyday speech in some
|
||||||
|
// language — AND and OR are SQL keywords too — so only its position at the
|
||||||
|
// start of a line tells a scenario block from a mention.
|
||||||
|
func ForeignConnectives(version int, code string) map[string]string {
|
||||||
|
own, err := Lookup(version, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
foreign := make(map[string]string)
|
||||||
|
for otherCode, other := range registry[version] {
|
||||||
|
if otherCode == code {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for w := range other.Scenario {
|
||||||
|
if _, mine := own.Scenario[w]; mine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
foreign[w] = otherCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foreign
|
||||||
|
}
|
||||||
|
|
||||||
func versions() string {
|
func versions() string {
|
||||||
out := make([]string, 0, len(registry))
|
out := make([]string, 0, len(registry))
|
||||||
for v := range registry {
|
for v := range registry {
|
||||||
|
|||||||
Reference in New Issue
Block a user