From 600aba5ee315c26769094f29fc97bc0481d86621 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 28 Jul 2026 10:16:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял --- CLAUDE.md | 38 ++++------- README.md | 15 ++++- internal/check/check_test.go | 54 +++++++++++++++- internal/check/copies.go | 40 ++++++++++++ internal/check/layers_test.go | 1 + internal/check/report.go | 32 ++++++++++ internal/check/spread.go | 9 +++ internal/check/suite.go | 45 +++++++------ internal/cli/check.go | 9 ++- internal/cli/project.go | 34 +++++++--- internal/cli/suitecheck.go | 22 ++++++- internal/cli/sync.go | 23 +++++++ internal/cli/sync_test.go | 105 +++++++++++++++++++++++++++++++ internal/lang/vocabulary.go | 15 ++--- internal/lang/vocabulary_test.go | 23 +++++++ internal/manifest/manifest.go | 18 +++++- 16 files changed, 413 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4bd3fdf..602ff1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,12 @@ internal/cli команды, диалог, два режима которую можно читать. Транспортов два, но `Kind` — перечисление, а не булево: третий (rclone, дерево по https) ожидается, и отказ обязан сначала сказать, чем ссылку сочли, и только потом — что не так. +- **Документ самоуправления объявлен, а не угадан.** `governance` в манифесте + называет файл, который написан языком конвенций, но не принадлежит теме. + Угадывание пробовали — «он один» и «у него нет ключей оси» верны и для + набора, у которого единственная конвенция потеряла `topic`, а потеря эта + дорогая: файл сохраняет все проверки формы и тихо теряет все проверки об + отъезде к потребителю. - **Проверки формы не знают про набор.** `checkRules`, `checkVersionLine`, `checkModalsOutside` и прочие принимают `lang.Vocabulary`, а не `*suite.Suite` — иначе `convy check` в проекте пришлось бы писать заново. Копия несёт язык @@ -164,34 +170,14 @@ internal/cli команды, диалог, два режима ## Известные остатки -- Набор без документа самоуправления, в котором конвенция потеряла `topic`, - проскочит: признаков «этот документ один» и «у него нет ключей слоя» не - хватает. Закрывается маркером в манифесте — правка формата, не сделана. -- Проверка пути канона считает путём любой токен `*.md`, который резолвится в - файл набора; упоминание `README.md` в конвенции она пометит ошибочно. На - текущем каноне не срабатывает. -- Машиночитаемого вывода находок (`--json`) нет. -- Предупреждения о висячей ссылке на неподписанную тему нет: `convy check` до - набора не дотягивается и разрешает только ссылки на префиксы самого файла. - Закрывается отдельной командой, а не этой: ей нужен список подписок. - Набор в поддиректории git-репозитория не адресуется: `#рев` есть, `//путь` - нет. Появится вместе с первым набором, который так лежит. + нет. Синтаксис, которого не просит ни один набор, не заводится заранее. - Источник у проекта один. Модель нескольких допускает; форма `source = "..."` - расширяется до `[sources.имя]` не ломая существующие манифесты. -- `convy add` пишет подписку после сборки — если сборка прошла, а запись в - манифест упала, копия останется неучтённой. Обратный порядок хуже: подписка - без файла отправляет следующий `pull` искать то, чего не делали. -- Директории компонентов сверяются на равенство, а не на вложенность. Компонент - в `docs` и компонент в `docs/sub` манифест пропустит; `convy check` от - двойных находок защищён отдельно (`distinct`). -- `lang.Recognize` при отсутствии словаря с совпавшим номером версии отдаёт - первого кандидата, у которого совпали слова. Пока версия в реестре одна, это - безвредно; со второй версией того же естественного языка станет неверно. -- Опечатка `convey` вместо `convy` в текстах ничем не ловится. Слова отличаются - одной буквой, и оба выглядят словами. -- Способ раздачи готовых бинарей не выбран. `eget` умеет релизы GitHub, а git - здесь свой (`git.vakhrushev.me`) — тянет ли он релизы Gitea, не проверено; - иначе остаются прямой URL или `go install`. + расширяется до `[sources.имя]`, не ломая существующие манифесты. +- `convy add` пишет подписку после сборки. Если сборка прошла, а запись упала, + копия останется неучтённой — но это уже поправимо: `convy sync` её либо + уберёт, либо соберёт заново, когда тему подпишут. +- Ни линтеров, ни CI. Раздача — `go install`, пока этого хватает. ## Тесты diff --git a/README.md b/README.md index 1ad3126..5511765 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ go install git.vakhrushev.me/av/convy@latest go build -o convy . ``` -Способ раздачи готовых бинарей пока не выбран. +Готовых бинарей пока нет: `go install` хватает. ## Команды @@ -150,7 +150,18 @@ errors: 1, warnings: 0 текста не даётся и остаётся работой читателя. Коды возврата: `0` — чисто, `1` — есть ошибки, `2` — команда набрана неверно или -не в том контексте. +не в том контексте. Обе проверки принимают `--json` — те же находки в том же +порядке, для вызывающего, который не человек: + +```json +{"findings":[{"severity":"error","family":"spread","path":"docs/conventions/time.md", +"line":22,"message":"..."}],"errors":1,"warnings":0} +``` + +Манифест набора называет ключом `governance` документ, которым набор ведёт +себя сам, — тот, что написан языком конвенций, но не принадлежит теме и потому +никуда не едет. Без этого ключа конвенция, потерявшая `topic`, была бы от него +неотличима. ## Подключение в проект diff --git a/internal/check/check_test.go b/internal/check/check_test.go index 22f6405..0ccace4 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -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))) + } +} diff --git a/internal/check/copies.go b/internal/check/copies.go index 9ddfc13..a1cf044 100644 --- a/internal/check/copies.go +++ b/internal/check/copies.go @@ -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 "" +} diff --git a/internal/check/layers_test.go b/internal/check/layers_test.go index ad43636..f106bc2 100644 --- a/internal/check/layers_test.go +++ b/internal/check/layers_test.go @@ -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" }, }} diff --git a/internal/check/report.go b/internal/check/report.go index 7bdff56..73e568d 100644 --- a/internal/check/report.go +++ b/internal/check/report.go @@ -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 +} diff --git a/internal/check/spread.go b/internal/check/spread.go index 327bbdc..0ce81e5 100644 --- a/internal/check/spread.go +++ b/internal/check/spread.go @@ -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 } diff --git a/internal/check/suite.go b/internal/check/suite.go index 04d5698..4ade139 100644 --- a/internal/check/suite.go +++ b/internal/check/suite.go @@ -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") } } } diff --git a/internal/cli/check.go b/internal/cli/check.go index 77a85aa..e30af04 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -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") forComponent := fs.String("for", "", "component to check; every one of them by default") 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 { return Usage } @@ -67,7 +68,13 @@ func runCheck(env Env, args []string) ExitCode { for _, err := range broken { 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 { return Failed } diff --git a/internal/cli/project.go b/internal/cli/project.go index 8f60557..7b0462d 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -158,21 +158,39 @@ func components(env Env, m *manifest.Project, name string) ([]string, ExitCode) return m.Names(), OK } -// distinctDirs checks that no two components write into the same directory. -// Two copies of one topic would otherwise collide by name, and that is an error -// of the manifest rather than a reason to rename files. +// distinctDirs checks that no two components write into the same place. Two +// copies of one topic would otherwise collide by name, and that is an error of +// 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 { - seen := make(map[string]string) + dirs := make(map[string]string, len(m.Components)) for _, name := range m.Names() { - dir := 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) + dirs[name] = filepath.ToSlash(filepath.Clean(m.Components[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 } +// 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. func exists(name string) bool { _, err := os.Stat(name) diff --git a/internal/cli/suitecheck.go b/internal/cli/suitecheck.go index 48d7d14..1b0a23b 100644 --- a/internal/cli/suitecheck.go +++ b/internal/cli/suitecheck.go @@ -18,6 +18,7 @@ func runSuiteCheck(env Env, args []string) ExitCode { fs.SetOutput(env.Err) 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") + 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 { return Usage } @@ -46,13 +47,32 @@ func runSuiteCheck(env Env, args []string) ExitCode { } 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 { return Failed } 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) { findings := rep.Findings() printFindings(w, findings) diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 797ebb3..f41a8e9 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "git.vakhrushev.me/av/convy/internal/check" "git.vakhrushev.me/av/convy/internal/manifest" "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) } + reportDangling(env, o, c) 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. type orphan struct { path string diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index bdfdcee..8a67f36 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "encoding/json" "os" "path/filepath" "strings" @@ -164,3 +165,107 @@ func exists(t *testing.T, parts ...string) bool { _, err := os.Stat(filepath.Join(parts...)) 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, "", "", 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"]) + } +} diff --git a/internal/lang/vocabulary.go b/internal/lang/vocabulary.go index 5283518..31893fd 100644 --- a/internal/lang/vocabulary.go +++ b/internal/lang/vocabulary.go @@ -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 // 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 -// carries. The version number decides between vocabularies naming the same -// words, which is what two versions of one natural language would do. +// carries. Both halves have to agree: the words say which natural language, +// 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) { - var best Vocabulary for _, version := range sortedVersions() { for _, code := range sortedCodes(version) { v := registry[version][code] - if !namesAll(text, v.Words()) { - continue - } - if best.Version == 0 || matchesVersion(text, v.Version) { - best = v + if namesAll(text, v.Words()) && matchesVersion(text, v.Version) { + return v, true } } } - return best, best.Version != 0 + return Vocabulary{}, false } func namesAll(text string, words []string) bool { diff --git a/internal/lang/vocabulary_test.go b/internal/lang/vocabulary_test.go index 7b907d5..4e80806 100644 --- a/internal/lang/vocabulary_test.go +++ b/internal/lang/vocabulary_test.go @@ -1,6 +1,7 @@ package lang_test import ( + "strings" "testing" "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") } } + +// 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) + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 8c7baee..b9d0738 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -88,8 +88,17 @@ func (s *Section) Retire(key, note string) { // Manifest is a parsed suite manifest. type Manifest struct { Language Language `toml:"language"` - Topics Section `toml:"topics,omitempty"` - Prefixes Section `toml:"prefixes,omitempty"` + // Governance is the path of the document the suite governs itself by: the + // 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 string `toml:"-"` @@ -207,6 +216,11 @@ func (m *Manifest) PathOf(prefix string) (string, bool) { 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. func (m *Manifest) TopicLive(topic string) bool { _, ok := m.Topics.Live[topic]