закрыты известные остатки

- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
This commit is contained in:
av
2026-07-28 10:16:27 +03:00
parent 29d29740f5
commit 600aba5ee3
16 changed files with 413 additions and 70 deletions
+51 -3
View File
@@ -374,15 +374,28 @@ func TestChecks(t *testing.T) {
},
want: "is not lower kebab-case",
}, {
name: "a second document without a topic",
name: "a convention that lost its topic next to the governing document",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
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",
want: "has lost the key",
}, {
name: "a convention that lost its topic in a suite with no governing document",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time\n", "", 1)
},
want: "the manifest names no document the suite governs itself by",
}, {
name: "the governing document declared with a topic",
setup: func(f files) {
f[".conventions-suite.toml"] = "governance = \"conventions/time.md\"\n" + baseManifest
},
want: "belongs to no topic",
}, {
name: "the short account of the language lost a word of the vocabulary",
setup: func(f files) {
@@ -390,14 +403,21 @@ func TestChecks(t *testing.T) {
},
want: "does not name ДОПУСКАЕТСЯ",
}, {
name: "document without a topic carries layer keys",
name: "the governing document carries layer keys",
setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"conventions/go.md\"\n" + f[".conventions-suite.toml"]
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
},
want: "carries the keys of a layer",
}, {
name: "governance names a file that is not there",
setup: func(f files) {
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + baseManifest
},
want: "governance names",
}}
for _, tc := range cases {
@@ -411,3 +431,31 @@ func TestChecks(t *testing.T) {
})
}
}
// The two names that mean something on the consumer's side as well: a
// convention naming either is talking about the copy, not about the suite.
func TestCanonPathLeavesTheConsumersOwnNames(t *testing.T) {
f := base()
f["README.md"] = "# Канон\n"
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
"Таблица тем собирается в README.md директории конвенций, а как читать\nправило — сказано в READING.md рядом с копиями.\n\n## Правила", 1)
for _, finding := range run(t, f) {
if strings.Contains(finding.Msg, "canon file path") {
t.Errorf("a name of the consumer's own was taken for a path of the suite: %s", finding.Msg)
}
}
// A path of a convention is still a path of a convention.
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
"Подробности — в conventions/time.md.\n\n## Правила", 1)
found := false
for _, finding := range run(t, f) {
if strings.Contains(finding.Msg, "canon file path") {
found = true
}
}
if !found {
t.Errorf("a path of a convention went unnoticed:\n%s", messages(run(t, f)))
}
}
+40
View File
@@ -6,6 +6,7 @@ import (
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Checking a copy is not checking a suite with parts left out. A copy has no
@@ -174,3 +175,42 @@ func Copies(docs []*doc.Document) *Report {
}
return rep
}
// Dangling lists the references a copy makes to rules of topics the component
// did not take. Such a reference resolves nowhere for its reader: the rule it
// names exists, but not in this repository.
//
// It is not an error. META-20 allows a convention to name a rule of another
// topic outside the norm, and a rationale that lost its addressee degrades
// honestly — the reader loses a pointer rather than the requirement. So this is
// something to look at, and it lives here rather than in Copy because it needs
// the suite, which a check of copies deliberately does not reach.
func Dangling(d *doc.Document, s *suite.Suite, subscribed func(topic string) bool) []Ref {
own := make(map[string]bool)
for _, r := range d.Rules {
own[r.Prefix] = true
}
var out []Ref
seen := make(map[string]bool)
for _, ref := range refsIn(d, d.Body, d.Len()) {
if own[ref.Prefix] || strings.HasPrefix(ref.Prefix, "X") || seen[ref.Prefix] {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic == "" || subscribed(target.Front.Topic) {
continue
}
seen[ref.Prefix] = true
out = append(out, ref)
}
return out
}
// TopicOf names the topic a prefix belongs to.
func TopicOf(s *suite.Suite, prefix string) string {
if d, ok := s.ByPrefix[prefix]; ok {
return d.Front.Topic
}
return ""
}
+1
View File
@@ -276,6 +276,7 @@ func TestNoFalsePositives(t *testing.T) {
f[".conventions-suite.toml"] = strings.Replace(layeredManifest,
`SLOG = "conventions/arch/logging.md"`,
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1)
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
},
}}
+32
View File
@@ -9,6 +9,7 @@
package check
import (
"encoding/json"
"fmt"
"sort"
)
@@ -104,3 +105,34 @@ func (r *Report) Errors() int {
func (r *Report) Warnings() int {
return len(r.findings) - r.Errors()
}
// MarshalJSON writes a finding the way a machine reads it: the severity and the
// family as words rather than as the numbers they happen to be inside.
func (f Finding) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Severity string `json:"severity"`
Family string `json:"family"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
Message string `json:"message"`
}{f.Severity.String(), string(f.Family), f.Path, f.Line, f.Msg})
}
// JSON renders the report for a caller that is not a person. Findings come out
// in the order they are printed in, so the two outputs never disagree about
// what was found first.
func (r *Report) JSON() ([]byte, error) {
out := struct {
Findings []Finding `json:"findings"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}{r.Findings(), r.Errors(), r.Warnings()}
if out.Findings == nil {
out.Findings = []Finding{}
}
body, err := json.Marshal(out)
if err != nil {
return nil, err
}
return append(body, '\n'), nil
}
+9
View File
@@ -8,6 +8,7 @@ import (
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/project"
"git.vakhrushev.me/av/convy/internal/suite"
)
@@ -135,6 +136,14 @@ func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Docume
// the suite holds. Unlike an extends key it needs no single answer: a path that
// fits several files of the canon is a path all the same.
func namesSuiteFile(s *suite.Suite, candidate string) bool {
// Two names mean something on the consumer's side as well, and a
// convention naming either of them is talking about the copy rather than
// about the suite: README.md belongs to the consuming repository, and
// READING.md is the guide that travels next to the copies.
switch path.Base(candidate) {
case "README.md", project.ReadingName:
return false
}
if s.Exists(candidate) {
return true
}
+27 -18
View File
@@ -131,31 +131,40 @@ func checkTopicNames(s *suite.Suite, rep *Report) {
// 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.
// 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) {
var topicless []string
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
}
topicless = append(topicless, d.Path)
if d.Front.Axis() || d.Front.Extends != "" {
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 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)
"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")
}
}
}