suite check: реализована проверка целостности набора

- разбор документа по языку конвенций: шапка, области правил, блоки под метками
- проверки формы правила, распространения, ссылок и самого suite.toml
- словарь языка живёт в бинаре реестром «версия × естественный язык», темы и
  префиксы берутся только из манифеста
This commit is contained in:
av
2026-07-27 09:53:07 +03:00
parent c557e72c79
commit ccf046fb7b
19 changed files with 2828 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
package check
import (
"path"
"regexp"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkSpread проверяет то, что относится к отъезду документа к потребителю.
// Применяется только к файлам конвенций: документ, которым набор ведёт себя
// сам, не уезжает никуда, и путь канона в нём законен.
func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkTopic(s, d, rep)
checkAxis(d, rep)
checkExtends(s, d, rep)
checkMechanized(s, d, rep)
checkCanonPaths(s, d, rep)
checkForeignTopicPrefix(s, d, rep)
}
// checkTopic сверяет тему из шапки с манифестом (META-28, META-29).
func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) {
topic := d.Front.Topic
at := d.Front.At["topic"]
switch {
case s.Manifest.TopicRetired(topic):
rep.Errorf(Spread, d.Path, at,
"тема %q значится среди выбывших: снятое имя другой теме не выдаётся", topic)
case !s.Manifest.TopicLive(topic):
rep.Errorf(Spread, d.Path, at,
"тема %q не объявлена в манифесте набора", topic)
}
}
// checkAxis сверяет объявленную ось с путём файла. Ось объявляется в шапке, а
// не выводится из пути (META-38); но если директории осей используются,
// расхождение означает переезд файла без правки шапки.
func checkAxis(d *doc.Document, rep *Report) {
parts := strings.Split(path.Dir(d.Path), "/")
for i := 0; i+1 < len(parts); i++ {
var declared, key string
switch parts[i] {
case "lang":
declared, key = d.Front.Lang, "lang"
case "stack":
declared, key = d.Front.Stack, "stack"
default:
continue
}
if declared == parts[i+1] {
continue
}
at := d.Front.At[key]
if at == 0 {
at = d.Front.At["prefix"]
}
rep.Errorf(Spread, d.Path, at,
"путь кладёт файл на ось %s=%s, а шапка объявляет %s=%q", key, parts[i+1], key, declared)
}
}
// checkExtends проверяет, что объявленная база существует и принадлежит той же
// теме. Ключ документирует связь слоёв для человека — документация, которая
// врёт, хуже отсутствующей.
func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
if d.Front.Extends == "" {
return
}
at := d.Front.At["extends"]
target := resolveExtends(s, d.Front.Extends)
if target == nil {
rep.Errorf(Spread, d.Path, at,
"extends указывает на %q, а такого файла в наборе нет", d.Front.Extends)
return
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, at,
"extends указывает на %q с темой %q, а файл несёт тему %q: слои одной темы объявляют одно имя",
d.Front.Extends, target.Front.Topic, d.Front.Topic)
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, at,
"extends указывает на %q, а это не базовый слой: у него объявлена ось", d.Front.Extends)
}
}
// resolveExtends ищет документ по пути, записанному в extends. Путь даётся от
// директории конвенций, поэтому пробуем и его, и путь от корня набора.
func resolveExtends(s *suite.Suite, ref string) *doc.Document {
ref = path.Clean(strings.TrimPrefix(ref, "./"))
for _, d := range s.Docs {
if d.Path == ref || strings.HasSuffix(d.Path, "/"+ref) {
return d
}
}
return nil
}
// checkMechanized ищет метку механизации в тексте конвенции. Механизирована
// норма или нет — свойство репозитория, а не набора, поэтому место отметки —
// локальная часть копии (META-7).
func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
word := s.Vocab.MarkWord(lang.Mechanized)
if word == "" {
return
}
version, hasVersion := versionParagraph(s, d)
d.Prose(func(n int, text string) bool {
if hasVersion && n >= version.Start && n <= version.End {
return true
}
if containsWord(text, word) {
rep.Errorf(Spread, d.Path, n,
"метка %s стоит в тексте конвенции: её место — запись о механизации в локальной части копии", word)
}
return true
})
}
// mdPathRe ловит то, что выглядит путём к файлу набора.
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
// checkCanonPaths ищет путь файла канона в тексте конвенции (META-21). В
// репозитории потребителя конвенция лежит собранной, слои одной темы — секции
// одного файла, и путь `lang/go/logging.md` там не существует: ссылка на него
// умирает при сборке, причём молча — текст остаётся связным.
//
// Инлайн-код здесь не вырезается: путь в бэктиках — тоже путь.
func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) {
target := resolveExtends(s, candidate)
if target == nil && !s.Exists(candidate) {
continue
}
rep.Errorf(Spread, d.Path, n,
"в тексте стоит путь файла канона %q: ссылаются именем темы или идентификатором правила", candidate)
}
}
}
// checkForeignTopicPrefix ищет префикс чужой темы в блоке нормы (META-20).
// Норму правила можно исполнить, имея один этот файл: репозиторий подписывается
// на произвольное подмножество конвенций, и графа зависимостей у него нет.
// Префикс базового слоя своей темы там допустим (META-24) — собранный файл
// начинается с него независимо от выбранных языка и стека.
func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d)
for _, r := range d.Rules {
for _, norm := range r.Norms() {
for _, ref := range refsIn(d, norm.Start, norm.End) {
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok {
continue
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, ref.Line,
"норма %s ссылается на %s из чужой темы %q: наружу смотрит только обоснование",
r.ID(), ref.Text, target.Front.Topic)
continue
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, ref.Line,
"норма %s ссылается на %s — слой своей темы, но не базовый: в копию он попадает по манифесту, и гарантии, что он рядом, нет",
r.ID(), ref.Text)
}
}
}
}
}