закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
This commit is contained in:
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user