Files
convy/internal/check/suite.go
T
av 51d2050200 suite check: закрыты пробелы в проверках шапки, иерархии и словаря
- документ без темы: ошибка, если такой не один или если он несёт ключи слоя —
  иначе конвенция, потерявшая topic, молча лишалась всех проверок распространения
- иерархия заголовков: один заголовок первого уровня, без перескоков через уровень
- имя темы: латиница и пригодность для имени файла — ошибкой, kebab-case —
  предупреждением
- сценарные связки чужого словаря ловятся в начале строки, где их ставит
  сценарный блок, а не в середине фразы, где это чаще SQL
2026-07-27 10:16:15 +03:00

181 lines
6.2 KiB
Go

package check
import (
"regexp"
"sort"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Suite runs every check of a suite and returns the report.
func Suite(s *suite.Suite) *Report {
rep := &Report{}
checkManifest(s, rep)
checkTopicNames(s, rep)
checkBaseLayers(s, rep)
checkSelfGoverning(s, rep)
for _, d := range s.Docs {
checkForm(s, d, rep)
checkLinks(s, d, rep)
if d.Front.Topic != "" {
checkSpread(s, d, rep)
}
}
return rep
}
// checkManifest checks the manifest itself: the shape of prefixes, live and
// retired not overlapping, the declared files being present and no undeclared
// ones lying around.
func checkManifest(s *suite.Suite, rep *Report) {
m := s.Manifest
for _, key := range m.Undecoded {
rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key)
}
for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) {
rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name)
}
}
byPath := make(map[string][]string)
for _, prefix := range m.LivePrefixes() {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
}
if m.PrefixRetired(prefix) {
rep.Errorf(Manifest, manifest.Name, 0,
"prefix %s is listed both live and retired: a retired one is never reissued", prefix)
}
path := m.Prefixes.Live[prefix]
byPath[path] = append(byPath[path], prefix)
}
for _, path := range sortedKeys(byPath) {
if prefixes := byPath[path]; len(prefixes) > 1 {
sort.Strings(prefixes)
rep.Errorf(Manifest, manifest.Name, 0,
"the file %q has several prefixes declared for it (%v): a prefix belongs to one file", path, prefixes)
}
}
for prefix := range m.Prefixes.Retired {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "among the retired ones: %s", err)
}
}
for _, prefix := range sortedKeys(s.Missing) {
rep.Errorf(Manifest, manifest.Name, 0,
"prefix %s is assigned to the file %q, which the suite does not hold", prefix, s.Missing[prefix])
}
for _, path := range s.Unregistered {
rep.Errorf(Manifest, path, 1,
"the file is written in the conventions language yet not declared in the suite manifest: for the suite it does not exist")
}
for _, err := range s.Broken {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
}
for _, topic := range m.LiveTopics() {
if m.TopicRetired(topic) {
rep.Errorf(Manifest, manifest.Name, 0,
"topic %q is listed both live and retired", topic)
}
if len(s.Layers(topic)) == 0 {
rep.Errorf(Manifest, manifest.Name, 0,
"topic %q is declared live while the suite holds no layer of it: a topic lives as long as at least one layer does", topic)
}
}
}
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
// 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.
func checkBaseLayers(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() {
var base []string
for _, d := range s.Layers(topic) {
if !d.Front.Axis() {
base = append(base, d.Path)
}
}
if len(base) > 1 {
sort.Strings(base)
for _, path := range base[1:] {
rep.Errorf(Spread, path, 1,
"topic %q holds more than one layer without axis keys: the base layer is the only one of its kind, and the candidates are %v",
topic, base)
}
}
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}