- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
258 lines
9.4 KiB
Go
258 lines
9.4 KiB
Go
package check
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
|
"git.vakhrushev.me/av/convy/internal/source"
|
|
"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)
|
|
checkLanguageSource(s, rep)
|
|
checkTopicNames(s, rep)
|
|
checkBaseLayers(s, rep)
|
|
checkSelfGoverning(s, rep)
|
|
checkReadingVocabulary(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)
|
|
}
|
|
// The documents about the language are named relative to the level they
|
|
// belong to. While that level is the suite, the files have to be here; once
|
|
// the language lives apart, they are out of reach of a check that runs on
|
|
// every edit and must not touch the network.
|
|
if m.Language.Source == "" {
|
|
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. A
|
|
// convention that lost its topic key looks exactly the same, and that loss is
|
|
// the expensive one — the file keeps every check of form while quietly dropping
|
|
// every check about travelling to a consumer.
|
|
//
|
|
// Which file it is, the manifest says. Guessing was tried and does not reach:
|
|
// "there is one of them" and "it has no axis keys" both hold for a suite whose
|
|
// only topic-less file is a convention with the key knocked out.
|
|
func checkSelfGoverning(s *suite.Suite, rep *Report) {
|
|
declared := s.Manifest.Governance
|
|
if declared != "" && !s.Exists(declared) {
|
|
rep.Errorf(Manifest, manifest.Name, 0,
|
|
"governance names %q, and the file is missing", declared)
|
|
}
|
|
|
|
for _, d := range s.Docs {
|
|
if d.Front.Topic != "" {
|
|
if s.Manifest.Governs(d.Path) {
|
|
rep.Errorf(Manifest, d.Path, d.Front.At["topic"],
|
|
"the manifest names this file the one the suite governs itself by, and it declares topic %q: such a document belongs to no topic, because nobody may subscribe to it", d.Front.Topic)
|
|
}
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case declared == "":
|
|
rep.Errorf(Manifest, d.Path, 1,
|
|
"the file declares no topic, and the manifest names no document the suite governs itself by: either the topic key is lost, or the manifest has to say governance = %q", d.Path)
|
|
case !s.Manifest.Governs(d.Path):
|
|
rep.Errorf(Manifest, d.Path, 1,
|
|
"the file declares no topic, while the manifest names %q as the one the suite governs itself by: a convention without a topic has lost the key", declared)
|
|
case d.Front.Axis() || d.Front.Extends != "":
|
|
rep.Errorf(Manifest, d.Path, d.Front.At["prefix"],
|
|
"the document the suite governs itself by carries the keys of a layer: it is nobody's layer, having no topic to be a layer of")
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkReadingVocabulary checks that the short account of the language names
|
|
// every word of the vocabulary (META-30).
|
|
//
|
|
// The full account stays with the author of the suite, while the rules are
|
|
// applied by the reader of a copy — a person or an agent in a foreign
|
|
// repository who holds the short one and nothing else. Let the two drift apart
|
|
// and that reader starts reading the words by an older version: ДОПУСКАЕТСЯ
|
|
// turns back into an everyday "you may", a departure from ДОЛЖЕН stops
|
|
// demanding a record. Exactly what the words were introduced for fails, and it
|
|
// fails in silence.
|
|
func checkReadingVocabulary(s *suite.Suite, rep *Report) {
|
|
name := s.Manifest.Language.Reading
|
|
if s.Manifest.Language.Source != "" {
|
|
// The account lies at a level of its own, and reaching it costs a
|
|
// fetch. Checking the integrity of a suite runs on every edit, so it
|
|
// stays local; the reference itself is checked instead.
|
|
return
|
|
}
|
|
if name == "" {
|
|
// A suite that has not written the document yet; `suite init` says so
|
|
// among the next steps, and repeating it on every run is noise.
|
|
return
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(s.Root, filepath.FromSlash(name)))
|
|
if err != nil {
|
|
// The missing file is reported by the manifest check already.
|
|
return
|
|
}
|
|
|
|
var missing []string
|
|
for _, word := range s.Vocab.Words() {
|
|
if !containsWord(string(body), word) {
|
|
missing = append(missing, word)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
rep.Errorf(Form, name, 0,
|
|
"the short account of the language does not name %s: it is the only key to the text of a rule a reader of a copy holds, and a word missing from it is a word read by whatever version the reader remembers",
|
|
strings.Join(missing, ", "))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// checkLanguageSource checks the reference to the level of the language. What
|
|
// it names cannot be reached without a fetch, and a check of a suite does not
|
|
// fetch — but a reference that means nothing is caught here rather than at the
|
|
// first assembly in a foreign repository.
|
|
func checkLanguageSource(s *suite.Suite, rep *Report) {
|
|
raw := s.Manifest.Language.Source
|
|
if raw == "" {
|
|
return
|
|
}
|
|
if _, err := source.Parse(raw); err != nil {
|
|
rep.Errorf(Manifest, manifest.Name, 0, "[language] source: %s", err)
|
|
}
|
|
}
|