diff --git a/internal/check/check_test.go b/internal/check/check_test.go index 19e8e50..9fa7cbd 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -323,6 +323,66 @@ func TestChecks(t *testing.T) { "Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1) }, want: "the text holds the canon file path", + }, { + name: "document opens below level one", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, "# Время", "## Время", 1) + }, + want: "opens with a level-2 heading", + }, { + name: "second level-one heading", + setup: func(f files) { + f["conventions/time.md"] = baseTime + "\n# Ещё один заголовок\n\nПроза.\n" + }, + want: "holds a second level-1 heading", + }, { + name: "heading skips a level", + setup: func(f files) { + f["conventions/time.md"] = strings.Replace(baseTime, "## Правила", "#### Правила", 1) + }, + want: "skipping a level", + }, { + name: "scenario block opens with a foreign connective", + setup: func(f files) { + f["conventions/time.md"] = baseTime + + "\nWHEN зависимость недоступна\nТОГДА запись ERROR\n" + }, + want: `the scenario block opens with WHEN from the "en" vocabulary`, + }, { + name: "topic name is not usable as a file name", + setup: func(f files) { + // A bare non-ASCII key TOML rejects on its own; a quoted one + // passes straight through, which is what the check is for. + f["suite.toml"] = strings.Replace(baseManifest, "time =", `"время" =`, 1) + f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: время", 1) + }, + want: "is not usable as a file name", + }, { + name: "topic name is not lower kebab-case", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, "time =", "Time_Zone =", 1) + f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: Time_Zone", 1) + }, + want: "is not lower kebab-case", + }, { + name: "a second document without a topic", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, + `TIME = "conventions/time.md"`, + `TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1) + 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", + }, { + name: "document without a topic carries layer keys", + setup: func(f files) { + f["suite.toml"] = strings.Replace(baseManifest, + `TIME = "conventions/time.md"`, + `TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1) + f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n" + }, + want: "carries the keys of a layer", }} for _, tc := range cases { diff --git a/internal/check/form.go b/internal/check/form.go index 45c7d04..ecc6643 100644 --- a/internal/check/form.go +++ b/internal/check/form.go @@ -18,11 +18,74 @@ import ( func checkForm(s *suite.Suite, d *doc.Document, rep *Report) { prefix := checkFilePrefix(s, d, rep) checkHeadings(d, prefix, rep) + checkHeadingHierarchy(d, rep) checkNumbering(d, prefix, rep) checkRules(s, d, rep) versionFrom, versionTo := checkVersionLine(s, d, rep) checkModalsOutside(s, d, versionFrom, versionTo, rep) checkForeignVocabulary(s, d, rep) + checkForeignConnectives(s, d, rep) +} + +// checkHeadingHierarchy checks the ladder of headings: one title, and no level +// skipped on the way down. A rule area runs from a heading to the next heading +// of any level, so a skipped level does not merely look untidy — it moves the +// boundary the whole parsing model rests on. +func checkHeadingHierarchy(d *doc.Document, rep *Report) { + if len(d.Headings) == 0 { + return + } + if first := d.Headings[0]; first.Level != 1 { + rep.Errorf(Form, d.Path, first.Line, + "the document opens with a level-%d heading, while the title of a document is a level-1 heading", first.Level) + } + + titles := 0 + prev := 0 + for _, h := range d.Headings { + if h.Level == 1 { + titles++ + if titles > 1 { + rep.Errorf(Form, d.Path, h.Line, + "the document holds a second level-1 heading %q: the title is one, everything below it is a section", h.Text) + } + } + if prev > 0 && h.Level > prev+1 { + rep.Errorf(Form, d.Path, h.Line, + "the heading %q jumps from level %d to level %d, skipping a level", h.Text, prev, h.Level) + } + prev = h.Level + } +} + +// checkForeignConnectives looks for scenario connectives of a foreign +// vocabulary. Only the start of a line counts: that is where a scenario block +// puts them, while mid-sentence AND and OR belong to SQL far more often than to +// a mixture of vocabularies. +func checkForeignConnectives(s *suite.Suite, d *doc.Document, rep *Report) { + foreign := lang.ForeignConnectives(s.Manifest.Language.Version, s.Manifest.Language.Lang) + if len(foreign) == 0 { + return + } + words := make([]string, 0, len(foreign)) + for w := range foreign { + words = append(words, w) + } + sort.Strings(words) + + d.Prose(func(n int, text string) bool { + text = strings.TrimLeft(text, " \t>-*") + for _, w := range words { + if !strings.HasPrefix(text, w) || letterAt(text, len(w)) { + continue + } + rep.Errorf(Form, d.Path, n, + "the scenario block opens with %s from the %q vocabulary, while the suite declares %q", + w, foreign[w], s.Manifest.Language.Lang) + break + } + return true + }) } // checkFilePrefix reconciles the prefix in the front matter with the manifest diff --git a/internal/check/layers_test.go b/internal/check/layers_test.go index 7fb5ec8..b534b22 100644 --- a/internal/check/layers_test.go +++ b/internal/check/layers_test.go @@ -222,6 +222,27 @@ func TestNoFalsePositives(t *testing.T) { f["conventions/arch/time.md"] = archTime + "\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n" }, + }, { + name: "a scenario block in the suite's own vocabulary", + setup: func(f files) { + f["conventions/arch/time.md"] = archTime + + "\n## Стык правил\n\nКОГДА зависимость недоступна И ретраи исчерпаны\nТОГДА запись делается один раз (TIME-1)\n" + }, + }, { + name: "an uppercase SQL connective mid-sentence outside a fence", + setup: func(f files) { + f["conventions/arch/time.md"] = strings.Replace(archTime, + "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", + "**ПОЧЕМУ.** Условие `WHERE a AND b OR c` сортировку не спасает.", 1) + }, + }, { + name: "the single document without a topic is the one the suite governs itself by", + setup: func(f files) { + f["suite.toml"] = strings.Replace(layeredManifest, + `SLOG = "conventions/arch/logging.md"`, + `SLOG = "conventions/arch/logging.md"`+"\nMETA = \"GUIDE.md\"", 1) + f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n" + }, }} for _, tc := range cases { diff --git a/internal/check/suite.go b/internal/check/suite.go index ee577ba..883a186 100644 --- a/internal/check/suite.go +++ b/internal/check/suite.go @@ -1,6 +1,7 @@ package check import ( + "regexp" "sort" "git.vakhrushev.me/av/convy/internal/manifest" @@ -11,7 +12,9 @@ import ( func Suite(s *suite.Suite) *Report { rep := &Report{} checkManifest(s, rep) + checkTopicNames(s, rep) checkBaseLayers(s, rep) + checkSelfGoverning(s, rep) for _, d := range s.Docs { checkForm(s, d, rep) @@ -87,6 +90,64 @@ func checkManifest(s *suite.Suite, rep *Report) { } } +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. 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. +func checkSelfGoverning(s *suite.Suite, rep *Report) { + var topicless []string + for _, d := range s.Docs { + if d.Front.Topic != "" { + continue + } + topicless = append(topicless, d.Path) + + if 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) + } + } +} + // 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. diff --git a/internal/lang/vocabulary.go b/internal/lang/vocabulary.go index d923434..23a8d73 100644 --- a/internal/lang/vocabulary.go +++ b/internal/lang/vocabulary.go @@ -267,6 +267,31 @@ func Foreign(version int, code string) map[string]string { return foreign } +// ForeignConnectives lists the scenario connectives of the other vocabularies +// of the same version. They are kept apart from Foreign because they are caught +// differently: a connective is a whole word of everyday speech in some +// language — AND and OR are SQL keywords too — so only its position at the +// start of a line tells a scenario block from a mention. +func ForeignConnectives(version int, code string) map[string]string { + own, err := Lookup(version, code) + if err != nil { + return nil + } + foreign := make(map[string]string) + for otherCode, other := range registry[version] { + if otherCode == code { + continue + } + for w := range other.Scenario { + if _, mine := own.Scenario[w]; mine { + continue + } + foreign[w] = otherCode + } + } + return foreign +} + func versions() string { out := make([]string, 0, len(registry)) for v := range registry {