suite check: закрыты пробелы в проверках шапки, иерархии и словаря

- документ без темы: ошибка, если такой не один или если он несёт ключи слоя —
  иначе конвенция, потерявшая topic, молча лишалась всех проверок распространения
- иерархия заголовков: один заголовок первого уровня, без перескоков через уровень
- имя темы: латиница и пригодность для имени файла — ошибкой, kebab-case —
  предупреждением
- сценарные связки чужого словаря ловятся в начале строки, где их ставит
  сценарный блок, а не в середине фразы, где это чаще SQL
This commit is contained in:
av
2026-07-27 10:16:15 +03:00
parent b2d07ae55d
commit 51d2050200
5 changed files with 230 additions and 0 deletions
+63
View File
@@ -18,11 +18,74 @@ import (
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
prefix := checkFilePrefix(s, d, rep)
checkHeadings(d, prefix, rep)
checkHeadingHierarchy(d, 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)
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