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

- документ самоуправления объявляется ключом 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
+12 -26
View File
@@ -86,6 +86,12 @@ internal/cli команды, диалог, два режима
которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево: которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево:
третий (rclone, дерево по https) ожидается, и отказ обязан сначала сказать, третий (rclone, дерево по https) ожидается, и отказ обязан сначала сказать,
чем ссылку сочли, и только потом — что не так. чем ссылку сочли, и только потом — что не так.
- **Документ самоуправления объявлен, а не угадан.** `governance` в манифесте
называет файл, который написан языком конвенций, но не принадлежит теме.
Угадывание пробовали — «он один» и «у него нет ключей оси» верны и для
набора, у которого единственная конвенция потеряла `topic`, а потеря эта
дорогая: файл сохраняет все проверки формы и тихо теряет все проверки об
отъезде к потребителю.
- **Проверки формы не знают про набор.** `checkRules`, `checkVersionLine`, - **Проверки формы не знают про набор.** `checkRules`, `checkVersionLine`,
`checkModalsOutside` и прочие принимают `lang.Vocabulary`, а не `*suite.Suite` `checkModalsOutside` и прочие принимают `lang.Vocabulary`, а не `*suite.Suite`
— иначе `convy check` в проекте пришлось бы писать заново. Копия несёт язык — иначе `convy check` в проекте пришлось бы писать заново. Копия несёт язык
@@ -164,34 +170,14 @@ internal/cli команды, диалог, два режима
## Известные остатки ## Известные остатки
- Набор без документа самоуправления, в котором конвенция потеряла `topic`,
проскочит: признаков «этот документ один» и «у него нет ключей слоя» не
хватает. Закрывается маркером в манифесте — правка формата, не сделана.
- Проверка пути канона считает путём любой токен `*.md`, который резолвится в
файл набора; упоминание `README.md` в конвенции она пометит ошибочно. На
текущем каноне не срабатывает.
- Машиночитаемого вывода находок (`--json`) нет.
- Предупреждения о висячей ссылке на неподписанную тему нет: `convy check` до
набора не дотягивается и разрешает только ссылки на префиксы самого файла.
Закрывается отдельной командой, а не этой: ей нужен список подписок.
- Набор в поддиректории git-репозитория не адресуется: `#рев` есть, `//путь` - Набор в поддиректории git-репозитория не адресуется: `#рев` есть, `//путь`
нет. Появится вместе с первым набором, который так лежит. нет. Синтаксис, которого не просит ни один набор, не заводится заранее.
- Источник у проекта один. Модель нескольких допускает; форма `source = "..."` - Источник у проекта один. Модель нескольких допускает; форма `source = "..."`
расширяется до `[sources.имя]` не ломая существующие манифесты. расширяется до `[sources.имя]`, не ломая существующие манифесты.
- `convy add` пишет подписку после сборкиесли сборка прошла, а запись в - `convy add` пишет подписку после сборки. Если сборка прошла, а запись упала,
манифест упала, копия останется неучтённой. Обратный порядок хуже: подписка копия останется неучтённой — но это уже поправимо: `convy sync` её либо
без файла отправляет следующий `pull` искать то, чего не делали. уберёт, либо соберёт заново, когда тему подпишут.
- Директории компонентов сверяются на равенство, а не на вложенность. Компонент - Ни линтеров, ни CI. Раздача — `go install`, пока этого хватает.
в `docs` и компонент в `docs/sub` манифест пропустит; `convy check` от
двойных находок защищён отдельно (`distinct`).
- `lang.Recognize` при отсутствии словаря с совпавшим номером версии отдаёт
первого кандидата, у которого совпали слова. Пока версия в реестре одна, это
безвредно; со второй версией того же естественного языка станет неверно.
- Опечатка `convey` вместо `convy` в текстах ничем не ловится. Слова отличаются
одной буквой, и оба выглядят словами.
- Способ раздачи готовых бинарей не выбран. `eget` умеет релизы GitHub, а git
здесь свой (`git.vakhrushev.me`) — тянет ли он релизы Gitea, не проверено;
иначе остаются прямой URL или `go install`.
## Тесты ## Тесты
+13 -2
View File
@@ -67,7 +67,7 @@ go install git.vakhrushev.me/av/convy@latest
go build -o convy . go build -o convy .
``` ```
Способ раздачи готовых бинарей пока не выбран. Готовых бинарей пока нет: `go install` хватает.
## Команды ## Команды
@@ -150,7 +150,18 @@ errors: 1, warnings: 0
текста не даётся и остаётся работой читателя. текста не даётся и остаётся работой читателя.
Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или
не в том контексте. не в том контексте. Обе проверки принимают `--json` — те же находки в том же
порядке, для вызывающего, который не человек:
```json
{"findings":[{"severity":"error","family":"spread","path":"docs/conventions/time.md",
"line":22,"message":"..."}],"errors":1,"warnings":0}
```
Манифест набора называет ключом `governance` документ, которым набор ведёт
себя сам, — тот, что написан языком конвенций, но не принадлежит теме и потому
никуда не едет. Без этого ключа конвенция, потерявшая `topic`, была бы от него
неотличима.
## Подключение в проект ## Подключение в проект
+51 -3
View File
@@ -374,15 +374,28 @@ func TestChecks(t *testing.T) {
}, },
want: "is not lower kebab-case", 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) { setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest, f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1) `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["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
f["conventions/rules.md"] = "---\nprefix: RULE\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", name: "the short account of the language lost a word of the vocabulary",
setup: func(f files) { setup: func(f files) {
@@ -390,14 +403,21 @@ func TestChecks(t *testing.T) {
}, },
want: "does not name ДОПУСКАЕТСЯ", want: "does not name ДОПУСКАЕТСЯ",
}, { }, {
name: "document without a topic carries layer keys", name: "the governing document carries layer keys",
setup: func(f files) { setup: func(f files) {
f[".conventions-suite.toml"] = strings.Replace(baseManifest, f[".conventions-suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1) `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" f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
}, },
want: "carries the keys of a layer", 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 { 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/doc"
"git.vakhrushev.me/av/convy/internal/lang" "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 // 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 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, f[".conventions-suite.toml"] = strings.Replace(layeredManifest,
`SLOG = "conventions/arch/logging.md"`, `SLOG = "conventions/arch/logging.md"`,
`SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1) `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" f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
}, },
}} }}
+32
View File
@@ -9,6 +9,7 @@
package check package check
import ( import (
"encoding/json"
"fmt" "fmt"
"sort" "sort"
) )
@@ -104,3 +105,34 @@ func (r *Report) Errors() int {
func (r *Report) Warnings() int { func (r *Report) Warnings() int {
return len(r.findings) - r.Errors() 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/doc"
"git.vakhrushev.me/av/convy/internal/lang" "git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/project"
"git.vakhrushev.me/av/convy/internal/suite" "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 // 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. // fits several files of the canon is a path all the same.
func namesSuiteFile(s *suite.Suite, candidate string) bool { 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) { if s.Exists(candidate) {
return true return true
} }
+27 -18
View File
@@ -131,31 +131,40 @@ func checkTopicNames(s *suite.Suite, rep *Report) {
// checkSelfGoverning guards the document without a topic. // checkSelfGoverning guards the document without a topic.
// //
// A topic-less document is the one the suite governs itself by: it travels // 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 // nowhere and cannot be subscribed to, so it gets no spread checks. A
// the manifest tells it from a convention that lost its topic key, and the // convention that lost its topic key looks exactly the same, and that loss is
// silent loss is the expensive one — the file keeps every check of form while // the expensive one — the file keeps every check of form while quietly dropping
// quietly dropping every check about travelling to a consumer. Two markers make // every check about travelling to a consumer.
// 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. // 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) { 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 { for _, d := range s.Docs {
if d.Front.Topic != "" { 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 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"], 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") "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")
}
}
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)
} }
} }
} }
+8 -1
View File
@@ -25,6 +25,7 @@ func runCheck(env Env, args []string) ExitCode {
root := fs.String("root", "", "root of the project; it is looked up upwards by default") root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to check; every one of them by default") forComponent := fs.String("for", "", "component to check; every one of them by default")
quiet := fs.Bool("quiet", false, "print findings only") quiet := fs.Bool("quiet", false, "print findings only")
asJSON := fs.Bool("json", false, "write the findings as JSON, for a caller that is not a person")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return Usage return Usage
} }
@@ -67,7 +68,13 @@ func runCheck(env Env, args []string) ExitCode {
for _, err := range broken { for _, err := range broken {
fmt.Fprintf(env.Err, "%s\n", err) fmt.Fprintf(env.Err, "%s\n", err)
} }
printCopyReport(env.Out, rep, len(docs), len(names), *quiet) if *asJSON {
if code := printJSON(env, rep); code != OK {
return code
}
} else {
printCopyReport(env.Out, rep, len(docs), len(names), *quiet)
}
if rep.Errors() > 0 || len(broken) > 0 { if rep.Errors() > 0 || len(broken) > 0 {
return Failed return Failed
} }
+26 -8
View File
@@ -158,21 +158,39 @@ func components(env Env, m *manifest.Project, name string) ([]string, ExitCode)
return m.Names(), OK return m.Names(), OK
} }
// distinctDirs checks that no two components write into the same directory. // distinctDirs checks that no two components write into the same place. Two
// Two copies of one topic would otherwise collide by name, and that is an error // copies of one topic would otherwise collide by name, and that is an error of
// of the manifest rather than a reason to rename files. // the manifest rather than a reason to rename files.
//
// One directory inside another is the same error told less plainly: whatever
// walks the outer one finds the copies of the inner, and every command that
// counts files counts them twice.
func distinctDirs(m *manifest.Project) error { func distinctDirs(m *manifest.Project) error {
seen := make(map[string]string) dirs := make(map[string]string, len(m.Components))
for _, name := range m.Names() { for _, name := range m.Names() {
dir := filepath.ToSlash(filepath.Clean(m.Components[name].Dir)) dirs[name] = filepath.ToSlash(filepath.Clean(m.Components[name].Dir))
if other, taken := seen[dir]; taken { }
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", other, name, dir) names := m.Names()
for i, a := range names {
for _, b := range names[i+1:] {
switch {
case dirs[a] == dirs[b]:
return fmt.Errorf("the components %q and %q share the directory %s: copies of one topic would collide there", a, b, dirs[a])
case within(dirs[a], dirs[b]):
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", b, dirs[b], a, dirs[a])
case within(dirs[b], dirs[a]):
return fmt.Errorf("the directory of the component %q (%s) lies inside the one of %q (%s): whatever walks the outer one finds the copies of the inner", a, dirs[a], b, dirs[b])
}
} }
seen[dir] = name
} }
return nil return nil
} }
// within reports whether inner lies under outer.
func within(outer, inner string) bool {
return strings.HasPrefix(inner, outer+"/")
}
// exists reports whether a path is there. // exists reports whether a path is there.
func exists(name string) bool { func exists(name string) bool {
_, err := os.Stat(name) _, err := os.Stat(name)
+21 -1
View File
@@ -18,6 +18,7 @@ func runSuiteCheck(env Env, args []string) ExitCode {
fs.SetOutput(env.Err) fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory") root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory")
quiet := fs.Bool("quiet", false, "print findings only") quiet := fs.Bool("quiet", false, "print findings only")
asJSON := fs.Bool("json", false, "write the findings as JSON, for a caller that is not a person")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return Usage return Usage
} }
@@ -46,13 +47,32 @@ func runSuiteCheck(env Env, args []string) ExitCode {
} }
rep := check.Suite(s) rep := check.Suite(s)
printReport(env.Out, rep, s, *quiet) if *asJSON {
if code := printJSON(env, rep); code != OK {
return code
}
} else {
printReport(env.Out, rep, s, *quiet)
}
if rep.Errors() > 0 { if rep.Errors() > 0 {
return Failed return Failed
} }
return OK return OK
} }
// printJSON writes the findings for a machine. It is the same report the person
// gets, in the same order — a second answer that disagreed with the first would
// be worse than no second answer.
func printJSON(env Env, rep *check.Report) ExitCode {
body, err := rep.JSON()
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
env.Out.Write(body)
return OK
}
func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) { func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
findings := rep.Findings() findings := rep.Findings()
printFindings(w, findings) printFindings(w, findings)
+23
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/manifest" "git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project" "git.vakhrushev.me/av/convy/internal/project"
) )
@@ -181,9 +182,31 @@ func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed,
} }
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide) fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
} }
reportDangling(env, o, c)
return changed, left, OK return changed, left, OK
} }
// reportDangling says which copies point at topics this component did not take.
// It is a note rather than a finding: naming a rule of another topic outside
// the norm is allowed, and the reader loses a pointer rather than a
// requirement. But it is also the one thing about a copy that cannot be seen
// without the suite, so it is said where the suite is at hand.
func reportDangling(env Env, o *opened, c manifest.Component) {
docs, _ := copies(o.Root, c.Dir)
for _, d := range docs {
refs := check.Dangling(d, o.Suite, c.Subscribed)
if len(refs) == 0 {
continue
}
var parts []string
for _, ref := range refs {
parts = append(parts, fmt.Sprintf("%s (%s)", ref.Text, check.TopicOf(o.Suite, ref.Prefix)))
}
fmt.Fprintf(env.Out, " ? %-28s points at %s — not taken by this component\n",
d.Path, strings.Join(parts, ", "))
}
}
// orphan is a copy in a component directory that the manifest does not call for. // orphan is a copy in a component directory that the manifest does not call for.
type orphan struct { type orphan struct {
path string path string
+105
View File
@@ -1,6 +1,7 @@
package cli_test package cli_test
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -164,3 +165,107 @@ func exists(t *testing.T, parts ...string) bool {
_, err := os.Stat(filepath.Join(parts...)) _, err := os.Stat(filepath.Join(parts...))
return err == nil return err == nil
} }
// One directory inside another is the same collision told less plainly, and it
// makes every command that walks a component find the copies of the other.
func TestNestedComponentDirectoriesAreRefused(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs", "--lang", "go")
addComponent(t, root, "web", manifest.Component{Dir: "docs/web", Topics: []string{}})
for _, name := range []string{"sync", "pull", "check"} {
t.Run(name, func(t *testing.T) {
code, out := run(t, root, "", false, name)
if code == cli.OK {
t.Fatalf("nested directories went through:\n%s", out)
}
if !strings.Contains(out, "lies inside") {
t.Errorf("the refusal does not say what is nested:\n%s", out)
}
})
}
}
// A copy may name a rule of a topic the component did not take: naming one
// outside the norm is allowed, and the reader loses a pointer rather than a
// requirement. It is still the one thing about a copy that cannot be seen
// without the suite, so sync says it.
func TestSyncNotesAReferenceToAnUnsubscribedTopic(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body += "\nПодробности — SLOG-1.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "sync")
if code != cli.OK {
t.Fatalf("sync returned %d: %s", code, out)
}
if !strings.Contains(out, "SLOG-1") || !strings.Contains(out, "logging") {
t.Errorf("the note does not name the reference and its topic:\n%s", out)
}
// Subscribed, it is no longer dangling.
run(t, root, "", false, "add", "logging")
_, out = run(t, root, "", false, "sync")
if strings.Contains(out, "not taken by this component") {
t.Errorf("the note stayed after the topic was taken:\n%s", out)
}
}
// The findings a person reads and the findings a machine reads are the same
// findings, in the same order.
func TestCheckWritesJSONForACallerThatIsNotAPerson(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
code, out := run(t, root, "", false, "check", "--json")
if code != cli.OK {
t.Fatalf("convy check --json returned %d: %s", code, out)
}
var clean struct {
Findings []map[string]any `json:"findings"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}
if err := json.Unmarshal([]byte(out), &clean); err != nil {
t.Fatalf("the output is not JSON: %v\n%s", err, out)
}
if len(clean.Findings) != 0 || clean.Errors != 0 {
t.Errorf("a sound project reported findings: %s", out)
}
// Now break it and read the finding back.
name := filepath.Join(root, "docs", "conventions", "time.md")
body := read(t, root, "docs/conventions/time.md")
body = strings.Replace(body, "<!-- conv:local -->", "", 1)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out = run(t, root, "", false, "check", "--json")
if code == cli.OK {
t.Fatalf("the broken copy passed:\n%s", out)
}
if err := json.Unmarshal([]byte(out), &clean); err != nil {
t.Fatalf("the output is not JSON: %v\n%s", err, out)
}
if clean.Errors != 1 || len(clean.Findings) != 1 {
t.Fatalf("expected one error, got %s", out)
}
f := clean.Findings[0]
for key, want := range map[string]any{"severity": "error", "family": "spread"} {
if f[key] != want {
t.Errorf("%s is %v, expected %v", key, f[key], want)
}
}
if f["path"] != "docs/conventions/time.md" {
t.Errorf("path is %v", f["path"])
}
}
+6 -9
View File
@@ -298,22 +298,19 @@ func Lookup(version int, code string) (Vocabulary, error) {
// and by nothing else: no manifest travels with it, and no path back to the // and by nothing else: no manifest travels with it, and no path back to the
// suite is written anywhere in it. So a check run outside a suite learns which // suite is written anywhere in it. So a check run outside a suite learns which
// words are normative the same way a reader does — off the line the document // words are normative the same way a reader does — off the line the document
// carries. The version number decides between vocabularies naming the same // carries. Both halves have to agree: the words say which natural language,
// words, which is what two versions of one natural language would do. // the number says which version of the language, and two versions of one
// natural language would otherwise be told apart by nothing at all.
func Recognize(text string) (Vocabulary, bool) { func Recognize(text string) (Vocabulary, bool) {
var best Vocabulary
for _, version := range sortedVersions() { for _, version := range sortedVersions() {
for _, code := range sortedCodes(version) { for _, code := range sortedCodes(version) {
v := registry[version][code] v := registry[version][code]
if !namesAll(text, v.Words()) { if namesAll(text, v.Words()) && matchesVersion(text, v.Version) {
continue return v, true
}
if best.Version == 0 || matchesVersion(text, v.Version) {
best = v
} }
} }
} }
return best, best.Version != 0 return Vocabulary{}, false
} }
func namesAll(text string, words []string) bool { func namesAll(text string, words []string) bool {
+23
View File
@@ -1,6 +1,7 @@
package lang_test package lang_test
import ( import (
"strings"
"testing" "testing"
"git.vakhrushev.me/av/convy/internal/lang" "git.vakhrushev.me/av/convy/internal/lang"
@@ -100,3 +101,25 @@ func TestRecognizeReadsTheLanguageOffTheVersionLine(t *testing.T) {
t.Errorf("prose naming no words passed for a version line") t.Errorf("prose naming no words passed for a version line")
} }
} }
// Words say which natural language, the number says which version. One without
// the other names no vocabulary: two versions of the same language would be
// told apart by nothing at all.
func TestRecognizeNeedsTheVersionToAgree(t *testing.T) {
ru, err := lang.Lookup(1, "ru")
if err != nil {
t.Fatal(err)
}
line := ru.VersionLine()
if _, ok := lang.Recognize(line); !ok {
t.Fatalf("the line the tool itself writes was not recognized")
}
wrong := strings.Replace(line, "версии 1", "версии 7", 1)
if wrong == line {
t.Fatal("the fixture did not change the version")
}
if v, ok := lang.Recognize(wrong); ok {
t.Errorf("a line naming version 7 was read as version %d (%s)", v.Version, v.Code)
}
}
+16 -2
View File
@@ -88,8 +88,17 @@ func (s *Section) Retire(key, note string) {
// Manifest is a parsed suite manifest. // Manifest is a parsed suite manifest.
type Manifest struct { type Manifest struct {
Language Language `toml:"language"` Language Language `toml:"language"`
Topics Section `toml:"topics,omitempty"` // Governance is the path of the document the suite governs itself by: the
Prefixes Section `toml:"prefixes,omitempty"` // one written in the conventions language yet belonging to no topic, so
// that nobody can subscribe to it and it travels nowhere.
//
// It is declared rather than guessed. A convention that lost its topic key
// looks exactly like it, and the loss is the expensive one — the file keeps
// every check of form while quietly dropping every check about travelling
// to a consumer.
Governance string `toml:"governance,omitempty"`
Topics Section `toml:"topics,omitempty"`
Prefixes Section `toml:"prefixes,omitempty"`
// Path is where the manifest was read from. // Path is where the manifest was read from.
Path string `toml:"-"` Path string `toml:"-"`
@@ -207,6 +216,11 @@ func (m *Manifest) PathOf(prefix string) (string, bool) {
return path, ok return path, ok
} }
// Governs reports whether the path is the document the suite governs itself by.
func (m *Manifest) Governs(path string) bool {
return m.Governance != "" && filepath.ToSlash(m.Governance) == filepath.ToSlash(path)
}
// TopicLive reports whether the topic is declared among the live ones. // TopicLive reports whether the topic is declared among the live ones.
func (m *Manifest) TopicLive(topic string) bool { func (m *Manifest) TopicLive(topic string) bool {
_, ok := m.Topics.Live[topic] _, ok := m.Topics.Live[topic]