комментарии и сообщения переведены на английский

- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском
- по-русски остались только литералы словаря ru и содержимое фикстур: это
  данные под проверкой, а не текст инструмента
- согласование числительных в итоге упростилось до английского plural
This commit is contained in:
av
2026-07-27 10:04:27 +03:00
parent 0b8cc125b3
commit b2d07ae55d
17 changed files with 548 additions and 524 deletions
+74 -67
View File
@@ -10,8 +10,13 @@ import (
"git.vakhrushev.me/av/convy/internal/suite"
)
// versionLine — строка о версии языка. Она перечисляет ключевые слова набора,
// поэтому единственная законно несёт модальные слова вне правил.
// The fixtures below stay in Russian on purpose: they are the data under test,
// written in the natural language the suite declares. Only the tool's own text
// is English.
// versionLine is the language version line. It lists the key words of the
// suite, which makes it the only place lawfully carrying modal words outside a
// rule.
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
@@ -53,8 +58,9 @@ prefix: TIME
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
// files — содержимое набора: путь от корня к тексту файла. Пустая строка
// значит «файла нет»: так тест снимает файл, который есть в основе.
// files is the content of a suite: a path from the root mapped to the text of
// the file. An empty string means "no such file": that is how a test drops a
// file the base fixture provides.
type files map[string]string
func base() files {
@@ -66,7 +72,7 @@ func base() files {
}
}
// run записывает набор во временную директорию и прогоняет проверки.
// run writes a suite into a temporary directory and runs the checks over it.
func run(t *testing.T, f files) []check.Finding {
t.Helper()
root := t.TempDir()
@@ -84,7 +90,7 @@ func run(t *testing.T, f files) []check.Finding {
}
s, err := suite.Load(root)
if err != nil {
t.Fatalf("загрузка набора: %v", err)
t.Fatalf("loading the suite: %v", err)
}
return check.Suite(s).Findings()
}
@@ -102,25 +108,26 @@ func messages(findings []check.Finding) string {
func TestCleanSuite(t *testing.T) {
if got := run(t, base()); len(got) != 0 {
t.Fatalf("исправный набор дал находки:\n%s", messages(got))
t.Fatalf("a sound suite produced findings:\n%s", messages(got))
}
}
// TestRuleHeadingIsNotAReference держит границу между объявлением и ссылкой.
// Заголовок с чужим префиксом — ошибка формы, и только она: разрешать заголовок
// по манифесту не к чему, иначе одна опечатка даёт две находки о разном.
// TestRuleHeadingIsNotAReference holds the line between a declaration and a
// reference. A heading with a foreign prefix is an error of form, and only
// that: there is nothing to resolve against the manifest, otherwise one typo
// would yield two findings about different things.
func TestRuleHeadingIsNotAReference(t *testing.T) {
f := base()
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
for _, got := range run(t, f) {
if got.Family == check.Links {
t.Errorf("заголовок разобран как ссылка: %s", got.Msg)
t.Errorf("a heading was parsed as a reference: %s", got.Msg)
}
}
}
// rule собирает правило целиком, чтобы тесты не повторяли его форму.
// rule builds a whole rule so that tests do not repeat its form.
func rule(id, title, norm, rationale string) string {
return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n"
}
@@ -131,191 +138,191 @@ func TestChecks(t *testing.T) {
setup func(files)
want string
}{{
name: "префикс в шапке расходится с манифестом",
name: "front matter prefix diverges from the manifest",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1)
},
want: "префикс в шапке — GTIM, а манифест объявляет за этим файлом TIME",
want: "the front matter declares prefix GTIM, while the manifest assigns TIME to this file",
}, {
name: "заголовок правила несёт чужой префикс",
name: "rule heading carries a foreign prefix",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
},
want: "использует префикс GTIM, а файлу принадлежит TIME",
want: "the rule heading uses prefix GTIM, while the file owns TIME",
}, {
name: "нумерация с дырой",
name: "numbering has a gap",
setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.")
},
want: "нумерация не сплошная",
want: "numbering is not contiguous",
}, {
name: "номер занят дважды",
name: "a number is taken twice",
setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.")
},
want: "номер TIME-1 занят дважды",
want: "number TIME-1 is taken twice",
}, {
name: "правило без обоснования",
name: "rule without a rationale",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1)
},
want: "нет блока ПОЧЕМУ: обоснование обязательно",
want: "has no ПОЧЕМУ block: the rationale is mandatory",
}, {
name: "правило без нормы и без заглушки",
name: "rule with neither a norm nor a stub",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1)
},
want: "нет ни блока нормы, ни заглушки СНЯТО",
want: "has neither a norm block nor a СНЯТО stub",
}, {
name: "две нормы под одним номером",
name: "two norms under one number",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1)
},
want: "две нормы (ДОЛЖЕН и СЛЕДУЕТ)",
want: "holds two norms (ДОЛЖЕН and СЛЕДУЕТ)",
}, {
name: "обоснование стоит раньше нормы",
name: "rationale precedes the norm",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1)
},
want: "обоснование стоит раньше нормы",
want: "the rationale precedes the norm",
}, {
name: "примеры стоят раньше обоснования",
name: "examples precede the rationale",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1)
},
want: "блок ПРИМЕРЫ стоит раньше обоснования",
want: "the ПРИМЕРЫ block precedes the rationale",
}, {
name: "заглушка снятого без даты",
name: "stub of a retired rule without a date",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**СНЯТО.** Правило убрано за ненадобностью.", 1)
},
want: "не несёт даты снятия",
want: "carries no date of retirement",
}, {
name: "у снятого правила осталась норма",
name: "retired rule still holds a norm",
setup: func(f files) {
f["conventions/time.md"] = baseTime +
"\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n"
},
want: "остался блок нормы",
want: "still holds a norm block",
}, {
name: "нет строки о версии языка",
name: "no language version line",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1)
},
want: "нет строки о версии языка",
want: "holds no language version line",
}, {
name: "строка о версии называет чужую версию",
name: "version line names a foreign version",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1)
},
want: "не называет версию 1",
want: "does not name version 1",
}, {
name: "модальное слово вне области правила",
name: "modal word outside a rule area",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1)
},
want: "стоит вне области правила",
want: "stands outside a rule area",
}, {
name: "слово чужого словаря",
name: "word of a foreign vocabulary",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1)
},
want: `слово MUST принадлежит словарю "en"`,
want: `the word MUST belongs to the "en" vocabulary`,
}, {
name: "ссылка на несуществующее правило",
name: "reference to a rule that does not exist",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри TIME-9. Без явного смещения", 1)
},
want: "ссылка TIME-9 не разрешается",
want: "reference TIME-9 does not resolve",
}, {
name: "ссылка на неизвестный префикс",
name: "reference to an unknown prefix",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1)
},
want: "префикс ZZZZ, которого в манифесте набора нет",
want: "prefix ZZZZ, which the suite manifest does not declare",
}, {
name: "тема не объявлена в манифесте",
name: "topic not declared in the manifest",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1)
},
want: `тема "clocks" не объявлена`,
want: `topic "clocks" is not declared`,
}, {
name: "тема значится среди выбывших",
name: "topic listed among the retired ones",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
"[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1)
},
want: "значится и среди живых, и среди выбывших",
want: "is listed both live and retired",
}, {
name: "у живой темы нет слоёв",
name: "live topic without layers",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`time = "время: хранение, зоны, форматы"`,
`time = "время"`+"\nlogging = \"логирование\"", 1)
},
want: `тема "logging" объявлена живой, а слоёв у неё в наборе нет`,
want: `topic "logging" is declared live while the suite holds no layer of it`,
}, {
name: "объявленного файла нет",
name: "declared file is missing",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1)
},
want: `префикс SLOG объявлен за файлом "conventions/logging.md", а файла в наборе нет`,
want: `prefix SLOG is assigned to the file "conventions/logging.md", which the suite does not hold`,
}, {
name: "файл не зарегистрирован в манифесте",
name: "file not registered in the manifest",
setup: func(f files) {
f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n"
},
want: "в манифесте набора не объявлен",
want: "not declared in the suite manifest",
}, {
name: "префикс начинается на X",
name: "prefix starts with X",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1)
f["conventions/time.md"] = strings.ReplaceAll(baseTime, "TIME", "XTIM")
},
want: "зарезервирована за локальными правилами потребителей",
want: "reserved for the local rules of consumers",
}, {
name: "на один файл объявлено два префикса",
name: "one file with two prefixes declared",
setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1)
},
want: "объявлено несколько префиксов",
want: "has several prefixes declared for it",
}, {
name: "ключ манифеста неизвестен",
name: "unknown manifest key",
setup: func(f files) {
f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
},
want: "инструменту неизвестен",
want: "is unknown to the tool",
}, {
name: "метка механизации в тексте конвенции",
name: "mechanization mark in convention text",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1)
},
want: "её место — запись о механизации в локальной части копии",
want: "its place is the note of mechanization in the local part of the copy",
}, {
name: "путь канона в тексте конвенции",
name: "canon path in convention text",
setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
},
want: "стоит путь файла канона",
want: "the text holds the canon file path",
}}
for _, tc := range cases {
@@ -324,7 +331,7 @@ func TestChecks(t *testing.T) {
tc.setup(f)
got := messages(run(t, f))
if !strings.Contains(got, tc.want) {
t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got)
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
}
})
}
+54 -52
View File
@@ -12,9 +12,9 @@ import (
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkForm проверяет форму правила разбором текста. Применяется к любому
// файлу, который язык употребляет, — и к конвенциям, и к документу, которым
// набор ведёт себя сам.
// checkForm checks the form of a rule by parsing text. It applies to any file
// the language employs — both the conventions and the document the suite
// governs itself by.
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
prefix := checkFilePrefix(s, d, rep)
checkHeadings(d, prefix, rep)
@@ -25,47 +25,47 @@ func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
checkForeignVocabulary(s, d, rep)
}
// checkFilePrefix сверяет префикс в шапке с манифестом и возвращает префикс,
// которым файлу положено пользоваться.
// checkFilePrefix reconciles the prefix in the front matter with the manifest
// and returns the prefix the file is supposed to use.
func checkFilePrefix(s *suite.Suite, d *doc.Document, rep *Report) string {
declared, _ := s.Manifest.PrefixOf(d.Path)
if !d.Front.Present {
rep.Errorf(Form, d.Path, 1, "у файла нет шапки, а манифест объявляет за ним префикс %s", declared)
rep.Errorf(Form, d.Path, 1, "the file has no front matter, while the manifest assigns prefix %s to it", declared)
return declared
}
if d.Front.Prefix == "" {
rep.Errorf(Form, d.Path, 1, "шапка не несёт ключа prefix")
rep.Errorf(Form, d.Path, 1, "the front matter carries no prefix key")
return declared
}
at := d.Front.At["prefix"]
if d.Front.Prefix != declared {
rep.Errorf(Form, d.Path, at,
"префикс в шапке — %s, а манифест объявляет за этим файлом %s", d.Front.Prefix, declared)
"the front matter declares prefix %s, while the manifest assigns %s to this file", d.Front.Prefix, declared)
}
if err := manifest.ValidPrefix(d.Front.Prefix); err != nil {
rep.Errorf(Form, d.Path, at, "%s", err)
}
if s.Manifest.PrefixRetired(d.Front.Prefix) {
rep.Errorf(Form, d.Path, at, "префикс %s значится среди выбывших", d.Front.Prefix)
rep.Errorf(Form, d.Path, at, "prefix %s is listed among the retired ones", d.Front.Prefix)
}
for _, key := range d.Front.Unknown {
rep.Warnf(Form, d.Path, d.Front.At[key], "ключ шапки %q инструменту неизвестен", key)
rep.Warnf(Form, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
}
return declared
}
// checkHeadings проверяет форму заголовков правил: собственный префикс файла,
// третий уровень, точка после идентификатора, название.
// checkHeadings checks the form of rule headings: the file's own prefix, the
// third level, a period after the identifier, a title.
func checkHeadings(d *doc.Document, prefix string, rep *Report) {
for _, r := range d.Rules {
if r.Prefix != prefix {
rep.Errorf(Form, d.Path, r.Line,
"заголовок правила использует префикс %s, а файлу принадлежит %s", r.Prefix, prefix)
"the rule heading uses prefix %s, while the file owns %s", r.Prefix, prefix)
}
if r.HeadingLevel != 3 {
rep.Errorf(Form, d.Path, r.Line,
"заголовок правила %s стоит на уровне %d, а правило — заголовок третьего уровня", r.ID(), r.HeadingLevel)
"the heading of rule %s sits at level %d, while a rule is a third-level heading", r.ID(), r.HeadingLevel)
}
if r.Malformed != "" {
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
@@ -73,10 +73,10 @@ func checkHeadings(d *doc.Document, prefix string, rep *Report) {
}
}
// checkNumbering проверяет сплошную нумерацию: от единицы до наибольшего без
// пропусков и без повторов (META-31). Дыра неотличима от опечатки в номере и
// от правила, которое забыли дописать, — поэтому её нет никогда, а снятое
// правило остаётся заглушкой.
// checkNumbering checks that numbering is contiguous: from one up to the
// highest, with no gaps and no repeats (META-31). A gap is indistinguishable
// from a typo in a number and from a rule someone forgot to finish — which is
// why there is never one, and a retired rule stays as a stub.
func checkNumbering(d *doc.Document, prefix string, rep *Report) {
seen := make(map[int][]int)
for _, r := range d.Rules {
@@ -100,7 +100,7 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) {
for _, n := range nums {
if lines := seen[n]; len(lines) > 1 {
rep.Errorf(Form, d.Path, lines[1],
"номер %s занят дважды: строки %s", ruleID(prefix, n), joinInts(lines))
"number %s is taken twice: lines %s", ruleID(prefix, n), joinInts(lines))
}
}
var gaps []int
@@ -111,15 +111,15 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) {
}
if len(gaps) > 0 {
rep.Errorf(Form, d.Path, d.Rules[0].Line,
"нумерация не сплошная: наибольший номер %d, пропущены %s — снятое правило остаётся заглушкой, а не исчезает",
"numbering is not contiguous: the highest number is %d, missing %s — a retired rule stays as a stub instead of disappearing",
highest, joinInts(gaps))
}
}
var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
// checkRules проверяет состав правила: либо норма с обоснованием, либо
// заглушка снятого. Ни норма, ни обоснование не удаляются никогда
// checkRules checks what a rule is made of: either a norm with a rationale, or
// the stub of a retired one. Neither the norm nor the rationale is ever deleted
// (META-8, META-10).
func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
v := s.Vocab
@@ -133,25 +133,25 @@ func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
switch len(norms) {
case 0:
rep.Errorf(Form, d.Path, r.Line,
"у правила %s нет ни блока нормы, ни заглушки %s", r.ID(), v.MarkWord(lang.Retired))
"rule %s has neither a norm block nor a %s stub", r.ID(), v.MarkWord(lang.Retired))
case 1:
if norms[0].Rest == "" {
rep.Errorf(Form, d.Path, norms[0].Start,
"у правила %s метка %s не открывает нормы: за ней пусто", r.ID(), norms[0].Word)
"in rule %s the %s mark opens no norm: nothing follows it", r.ID(), norms[0].Word)
}
default:
rep.Errorf(Form, d.Path, norms[1].Start,
"у правила %s две нормы (%s и %s): норма — одна фраза, иначе нарушение одной её половины нечем адресовать",
"rule %s holds two norms (%s and %s): a norm is a single statement, otherwise a violation of one half of it has no address",
r.ID(), norms[0].Word, norms[1].Word)
}
rationale, ok := r.Block(lang.Rationale)
if !ok {
rep.Errorf(Form, d.Path, r.Line,
"у правила %s нет блока %s: обоснование обязательно", r.ID(), v.MarkWord(lang.Rationale))
"rule %s has no %s block: the rationale is mandatory", r.ID(), v.MarkWord(lang.Rationale))
} else if len(norms) > 0 && rationale.Start < norms[0].Start {
rep.Errorf(Form, d.Path, rationale.Start,
"у правила %s обоснование стоит раньше нормы: порядок блоков — норма, %s, %s",
"in rule %s the rationale precedes the norm: the order of blocks is norm, %s, %s",
r.ID(), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
}
@@ -159,57 +159,59 @@ func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
switch {
case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start:
rep.Errorf(Form, d.Path, examples.Start,
"у правила %s блок %s открывает правило: порядок блоков — норма, %s, %s",
"in rule %s the %s block opens the rule: the order of blocks is norm, %s, %s",
r.ID(), v.MarkWord(lang.Examples), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
case ok && rationale.Start > examples.Start:
rep.Errorf(Form, d.Path, examples.Start,
"у правила %s блок %s стоит раньше обоснования: сначала требование, потом причина, потом иллюстрация",
"in rule %s the %s block precedes the rationale: the requirement first, then the reason, then the illustration",
r.ID(), v.MarkWord(lang.Examples))
}
}
}
}
// checkRetired проверяет заглушку снятого правила: дата и причина.
// checkRetired checks the stub of a retired rule: a date and a reason.
func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
if len(r.Norms()) > 0 {
rep.Errorf(Form, d.Path, retired.Start,
"у снятого правила %s остался блок нормы: норму с обоснованием заменяет заглушка", r.ID())
"retired rule %s still holds a norm block: the stub replaces the norm together with the rationale", r.ID())
}
if !dateRe.MatchString(d.Line(retired.Start)) {
rep.Errorf(Form, d.Path, retired.Start,
"заглушка правила %s не несёт даты снятия", r.ID())
"the stub of rule %s carries no date of retirement", r.ID())
}
if strings.TrimSpace(retired.Rest) == "" {
rep.Errorf(Form, d.Path, retired.Start,
"заглушка правила %s не несёт причины снятия", r.ID())
"the stub of rule %s carries no reason for retirement", r.ID())
}
}
// checkVersionLine ищет во вводной прозе строку о версии языка и возвращает
// границы абзаца, который её несёт.
// checkVersionLine looks for the language version line in the introductory
// prose and returns the bounds of the paragraph carrying it.
//
// Строка перечисляет ключевые слова набора и сама несёт правило заглавных —
// поэтому она единственное место вне правил, где модальные слова законны.
// The line lists the key words of the suite and carries the rule of capitals
// itself — which makes it the only place outside rules where modal words are
// lawful.
func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(s, d)
if !ok {
start, _ := d.Preamble()
rep.Errorf(Form, d.Path, start,
"во вводной прозе нет строки о версии языка: она перечисляет ключевые слова набора и без неё конвенция в чужом репозитории теряет ключ к собственному тексту")
"the introductory prose holds no language version line: it lists the key words of the suite, and without it a convention in a foreign repository loses the key to its own text")
return 0, 0
}
version := strconv.Itoa(s.Manifest.Language.Version)
if !containsNumber(p.Text(), version) {
rep.Errorf(Form, d.Path, p.Start,
"строка о версии языка не называет версию %s, объявленную манифестом набора", version)
"the language version line does not name version %s declared by the suite manifest", version)
}
return p.Start, p.End
}
// versionParagraph ищет во вводной прозе абзац, несущий строку о версии языка:
// тот, где перечислены все ключевые слова набора. Ничего не сообщает — о его
// отсутствии говорит checkVersionLine, и говорить дважды незачем.
// versionParagraph looks in the introductory prose for the paragraph carrying
// the language version line: the one listing every key word of the suite. It
// reports nothing — checkVersionLine speaks about its absence, and speaking
// twice helps no one.
func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
from, to := d.Preamble()
words := s.Vocab.Words()
@@ -221,9 +223,9 @@ func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
return doc.Paragraph{}, false
}
// checkModalsOutside ищет заглавные модальные слова вне областей правил.
// Область — от заголовка правила до следующего заголовка; всё остальное проза,
// а проза нормой не является никогда.
// checkModalsOutside looks for capitalized modal words outside rule areas. An
// area runs from the heading of a rule to the next heading; everything else is
// prose, and prose is never a norm.
func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(s.Vocab)
d.Prose(func(n int, text string) bool {
@@ -235,16 +237,16 @@ func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo
continue
}
rep.Errorf(Form, d.Path, n,
"модальное слово %s стоит вне области правила: заглавное написание нормативно, и в прозе его быть не может", w)
"the modal word %s stands outside a rule area: capitalized spelling is normative, and prose cannot hold it", w)
break
}
return true
})
}
// checkForeignVocabulary ищет слова чужого словаря той же версии языка.
// Словарь один на набор: две формы записи одного требования удваивают каждую
// проверку.
// checkForeignVocabulary looks for words of another vocabulary of the same
// language version. There is one vocabulary per suite: two ways of writing the
// same requirement double every check.
func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang)
if len(foreign) == 0 {
@@ -260,7 +262,7 @@ func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
for _, w := range words {
if containsWord(text, w) {
rep.Errorf(Form, d.Path, n,
"слово %s принадлежит словарю %q, а набор объявляет словарь %q",
"the word %s belongs to the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
}
}
@@ -286,8 +288,8 @@ func containsAll(text string, words []string) bool {
return true
}
// containsWord ищет слово как целое: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не
// является, а «ДОЛЖЕН.» — является.
// containsWord looks for a word as a whole: "MUSTARD" is not the word "MUST",
// while "MUST." is.
func containsWord(text, word string) bool {
for i := 0; ; {
j := strings.Index(text[i:], word)
+27 -27
View File
@@ -5,9 +5,9 @@ import (
"testing"
)
// Набор с двумя слоями одной темы: базовый арх-слой и языковой поверх него.
// На нём проверяется всё, что про оси, extends и границу самодостаточности
// нормы, — на одном слое эти проверки выразить нечем.
// A suite with two layers of one topic: a base architectural layer and a
// language layer on top of it. Everything about axes, extends and the boundary
// of a self-sufficient norm is checked here — a single layer cannot express it.
const layeredManifest = `
[language]
@@ -104,7 +104,7 @@ func layered() files {
func TestLayeredSuiteIsClean(t *testing.T) {
if got := run(t, layered()); len(got) != 0 {
t.Fatalf("исправный многослойный набор дал находки:\n%s", messages(got))
t.Fatalf("a sound layered suite produced findings:\n%s", messages(got))
}
}
@@ -114,27 +114,27 @@ func TestLayeredChecks(t *testing.T) {
setup func(files)
want string
}{{
name: "ось в шапке расходится с путём",
name: "axis in the front matter diverges from the path",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
},
want: `путь кладёт файл на ось lang=go, а шапка объявляет lang="python"`,
want: `the path puts the file on axis lang=go, while the front matter declares lang="python"`,
}, {
name: "extends ведёт в чужую тему",
name: "extends leads into a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/logging.md", 1)
},
want: `с темой "logging", а файл несёт тему "time"`,
want: `with topic "logging", while the file carries topic "time"`,
}, {
name: "extends ведёт в несуществующий файл",
name: "extends leads into a file that does not exist",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/clocks.md", 1)
},
want: `а такого файла в наборе нет`,
want: `and the suite holds no such file`,
}, {
name: "у темы два слоя без ключей оси",
name: "topic with two layers lacking axis keys",
setup: func(f files) {
f["suite.toml"] = strings.Replace(layeredManifest,
`GTIM = "conventions/lang/go/time.md"`,
@@ -144,23 +144,23 @@ func TestLayeredChecks(t *testing.T) {
strings.Replace(goTime, "lang: go\n", "", 1),
"extends: arch/time.md\n", "", 1)
},
want: "больше одного слоя без ключей оси",
want: "more than one layer without axis keys",
}, {
name: "норма ссылается на префикс чужой темы",
name: "norm references the prefix of a foreign topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
},
want: `ссылается на SLOG-1 из чужой темы "logging"`,
want: `refers to SLOG-1 from the foreign topic "logging"`,
}, {
name: "норма ссылается на неба́зовый слой своей темы",
name: "norm references a non-base layer of its own topic",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
},
want: "слой своей темы, но не базовый",
want: "a layer of its own topic but not the base one",
}}
for _, tc := range cases {
@@ -169,55 +169,55 @@ func TestLayeredChecks(t *testing.T) {
tc.setup(f)
got := messages(run(t, f))
if !strings.Contains(got, tc.want) {
t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got)
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
}
})
}
}
// TestNoFalsePositives собирает случаи, в которых проверка обязана промолчать.
// Ложное срабатывание здесь дороже пропуска: проверку, которая краснеет на
// исправном файле, выключают целиком.
// TestNoFalsePositives gathers the cases where a check must stay silent. A
// false positive costs more here than a miss: a check that goes red on a sound
// file gets switched off altogether.
func TestNoFalsePositives(t *testing.T) {
cases := []struct {
name string
setup func(files)
}{{
name: "идентификатор в бэктиках — образец записи, а не ссылка",
name: "an identifier in backticks is a sample of notation, not a reference",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"Без явного смещения",
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
},
}, {
name: "модальное слово внутри огороженного блока кода",
name: "a modal word inside a fenced code block",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
},
}, {
name: "заглавное SQL-слово в примере кода",
name: "an uppercase SQL keyword in a code sample",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1)
},
}, {
name: "норма языкового слоя ссылается на базовый слой своей темы",
name: "a language layer's norm references the base layer of its own topic",
setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
},
}, {
name: "упоминание ступени в обосновании — не вторая норма",
name: "a mention of a step in the rationale is not a second norm",
setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
},
}, {
name: "заглушка снятого правила с датой и причиной",
name: "a stub of a retired rule with a date and a reason",
setup: func(f files) {
f["conventions/arch/time.md"] = archTime +
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n"
@@ -229,7 +229,7 @@ func TestNoFalsePositives(t *testing.T) {
f := layered()
tc.setup(f)
if got := run(t, f); len(got) != 0 {
t.Fatalf("проверка сработала там, где не должна:\n%s", messages(got))
t.Fatalf("a check fired where it must not:\n%s", messages(got))
}
})
}
+23 -23
View File
@@ -9,11 +9,11 @@ import (
"git.vakhrushev.me/av/convy/internal/suite"
)
// refRe ловит идентификатор правила: четыре заглавные латинские буквы, дефис,
// номер и необязательный номер строки таблицы.
// refRe catches a rule identifier: four uppercase Latin letters, a hyphen, a
// number and an optional table row number.
var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`)
// Ref — ссылка на правило, найденная в тексте.
// Ref is a reference to a rule found in the text.
type Ref struct {
Prefix string
Num int
@@ -22,13 +22,13 @@ type Ref struct {
Text string
}
// refsIn собирает ссылки в диапазоне строк документа. Инлайн-код вырезан: в
// бэктиках идентификатор стоит образцом записи, а не ссылкой на утверждение, —
// иначе строка «на конкретное правило ссылаются идентификатором (`SLOG-27`)»
// требовала бы, чтобы правило SLOG-27 существовало.
// refsIn collects the references in a range of lines. Inline code is cut out:
// inside backticks an identifier stands as a sample of the notation rather than
// as a reference to an assertion — otherwise the line "a rule is referred to by
// its identifier (`SLOG-27`)" would demand that rule SLOG-27 exist.
//
// Заголовки правил пропускаются: заголовок правило объявляет, а не ссылается
// на него, и разрешать его по манифесту не к чему.
// Rule headings are skipped: a heading declares a rule instead of referring to
// one, and there is nothing to resolve against the manifest.
func refsIn(d *doc.Document, from, to int) []Ref {
heading := make(map[int]bool, len(d.Rules))
for _, r := range d.Rules {
@@ -60,48 +60,48 @@ func refsInLine(n int, text string) []Ref {
return out
}
// checkLinks проверяет, что каждая ссылка разрешается. Неразрешённый
// идентификатор всегда ошибка: с заглушками на месте снятых правил третьего
// исхода нет — ссылка ведёт либо к правилу, либо к объяснению, почему его
// сняли (META-31, META-32).
// checkLinks verifies that every reference resolves. An unresolved identifier
// is always an error: with stubs standing in for retired rules there is no
// third outcome — a reference leads either to a rule or to the explanation of
// why it was retired (META-31, META-32).
func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) {
for _, ref := range refsIn(d, d.Body, d.Len()) {
if strings.HasPrefix(ref.Prefix, "X") {
// Префикс потребителя: локальные правила чужого репозитория
// набору не видны и разрешению не подлежат.
// A consumer's prefix: the local rules of a foreign repository
// are invisible to the suite and are not to be resolved.
continue
}
if s.Manifest.PrefixRetired(ref.Prefix) {
rep.Errorf(Links, d.Path, ref.Line,
"ссылка %s ведёт на выбывший префикс %s", ref.Text, ref.Prefix)
"reference %s points at retired prefix %s", ref.Text, ref.Prefix)
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok {
if _, declared := s.Manifest.PathOf(ref.Prefix); declared {
// Файл объявлен, но не прочитан — о нём уже сказано
// проверкой манифеста, второй раз не повторяем.
// The file is declared but was not read — the manifest check
// has already said so, and saying it twice helps no one.
continue
}
rep.Errorf(Links, d.Path, ref.Line,
"ссылка %s ведёт на префикс %s, которого в манифесте набора нет", ref.Text, ref.Prefix)
"reference %s points at prefix %s, which the suite manifest does not declare", ref.Text, ref.Prefix)
continue
}
rule, ok := ruleByNum(target, ref.Num)
if !ok {
rep.Errorf(Links, d.Path, ref.Line,
"ссылка %s не разрешается: в %s правила с номером %d нет", ref.Text, target.Path, ref.Num)
"reference %s does not resolve: %s holds no rule numbered %d", ref.Text, target.Path, ref.Num)
continue
}
if ref.Sub > 0 && !mentions(target, rule, ref.Text) {
rep.Errorf(Links, d.Path, ref.Line,
"ссылка %s не разрешается: в области %s такой строки нет", ref.Text, rule.ID())
"reference %s does not resolve: the area of %s holds no such row", ref.Text, rule.ID())
}
}
}
// mentions отвечает, встречается ли текст ссылки в области правила. Так
// проверяется номер строки таблицы: сама строка его и несёт.
// mentions reports whether the text of a reference occurs inside the area of a
// rule. That is how a table row number is checked: the row itself carries it.
func mentions(d *doc.Document, rule doc.Rule, text string) bool {
for n := rule.Line; n <= rule.End; n++ {
if strings.Contains(d.Line(n), text) {
+26 -26
View File
@@ -1,11 +1,11 @@
// Package check прогоняет проверки целостности набора.
// Package check runs the integrity checks of a suite.
//
// Деление на семейства взято из языка и сохранено в коде: форма правила
// проверяется в любом файле, который язык употребляет; распространение — только
// в файлах конвенций, потому что эти проверки о том, что документ уезжает к
// потребителю. Третья часть списка — взаимоисключительность строк таблицы,
// покрытие области действия, самодостаточность нормы — сюда не входит: она не
// даётся разбором текста и остаётся работой читателя.
// The split into families is taken from the language and kept in the code: the
// form of a rule is checked in any file the language employs; spread only in
// convention files, because those checks are about a document travelling to a
// consumer. The third part of the list — rows of a table being mutually
// exclusive, the scope being covered, a norm being self-sufficient — is not
// here: it does not yield to parsing text and stays the reader's work.
package check
import (
@@ -13,8 +13,8 @@ import (
"sort"
)
// Severity различает ошибку и предупреждение. Ошибка — нарушение, названное
// правилом набора; предупреждение — то, что стоит посмотреть глазами.
// Severity tells an error from a warning. An error is a violation named by a
// rule of the suite; a warning is something worth a look.
type Severity int
const (
@@ -24,43 +24,44 @@ const (
func (s Severity) String() string {
if s == Warning {
return "предупреждение"
return "warning"
}
return "ошибка"
return "error"
}
// Family — семейство проверок, из которого пришла находка.
// Family is the family of checks a finding came from.
type Family string
const (
Manifest Family = "манифест"
Form Family = "форма"
Spread Family = "распространение"
Links Family = "ссылки"
Manifest Family = "manifest"
Form Family = "form"
Spread Family = "spread"
Links Family = "links"
)
// Finding — одна находка.
// Finding is a single finding.
type Finding struct {
Severity Severity
Family Family
// Path — путь файла от корня набора; пусто, если находка о наборе целиком.
// Path is the path of the file from the root of the suite; empty when the
// finding is about the suite as a whole.
Path string
// Line — строка файла; ноль, если находка не привязана к строке.
// Line is the line of the file; zero when the finding is not bound to one.
Line int
Msg string
}
// Report накапливает находки одного прогона.
// Report accumulates the findings of one run.
type Report struct {
findings []Finding
}
// Errorf записывает ошибку.
// Errorf records an error.
func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) {
r.add(Error, f, path, line, format, args...)
}
// Warnf записывает предупреждение.
// Warnf records a warning.
func (r *Report) Warnf(f Family, path string, line int, format string, args ...any) {
r.add(Warning, f, path, line, format, args...)
}
@@ -75,8 +76,7 @@ func (r *Report) add(s Severity, f Family, path string, line int, format string,
})
}
// Findings отдаёт находки в порядке файла и строки. Находки о наборе целиком
// идут первыми: пока манифест не сходится, остальное читать рано.
// Findings hands over the findings ordered by file and line.
func (r *Report) Findings() []Finding {
out := make([]Finding, len(r.findings))
copy(out, r.findings)
@@ -89,7 +89,7 @@ func (r *Report) Findings() []Finding {
return out
}
// Errors считает находки уровня ошибки.
// Errors counts the findings of error severity.
func (r *Report) Errors() int {
n := 0
for _, f := range r.findings {
@@ -100,7 +100,7 @@ func (r *Report) Errors() int {
return n
}
// Warnings считает предупреждения.
// Warnings counts the warnings.
func (r *Report) Warnings() int {
return len(r.findings) - r.Errors()
}
+42 -36
View File
@@ -10,9 +10,9 @@ import (
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkSpread проверяет то, что относится к отъезду документа к потребителю.
// Применяется только к файлам конвенций: документ, которым набор ведёт себя
// сам, не уезжает никуда, и путь канона в нём законен.
// checkSpread checks what bears on a document travelling to a consumer. It
// applies to convention files only: the document the suite governs itself by
// travels nowhere, and a canon path inside it is lawful.
func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkTopic(s, d, rep)
checkAxis(d, rep)
@@ -22,23 +22,25 @@ func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkForeignTopicPrefix(s, d, rep)
}
// checkTopic сверяет тему из шапки с манифестом (META-28, META-29).
// checkTopic reconciles the topic from the front matter with the manifest
// (META-28, META-29).
func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) {
topic := d.Front.Topic
at := d.Front.At["topic"]
switch {
case s.Manifest.TopicRetired(topic):
rep.Errorf(Spread, d.Path, at,
"тема %q значится среди выбывших: снятое имя другой теме не выдаётся", topic)
"topic %q is listed among the retired ones: a retired name is never handed to another topic", topic)
case !s.Manifest.TopicLive(topic):
rep.Errorf(Spread, d.Path, at,
"тема %q не объявлена в манифесте набора", topic)
"topic %q is not declared in the suite manifest", topic)
}
}
// checkAxis сверяет объявленную ось с путём файла. Ось объявляется в шапке, а
// не выводится из пути (META-38); но если директории осей используются,
// расхождение означает переезд файла без правки шапки.
// checkAxis reconciles the declared axis with the path of the file. An axis is
// declared in the front matter rather than derived from the path (META-38); but
// once axis directories are in use, a divergence means the file moved and the
// front matter did not.
func checkAxis(d *doc.Document, rep *Report) {
parts := strings.Split(path.Dir(d.Path), "/")
for i := 0; i+1 < len(parts); i++ {
@@ -59,13 +61,13 @@ func checkAxis(d *doc.Document, rep *Report) {
at = d.Front.At["prefix"]
}
rep.Errorf(Spread, d.Path, at,
"путь кладёт файл на ось %s=%s, а шапка объявляет %s=%q", key, parts[i+1], key, declared)
"the path puts the file on axis %s=%s, while the front matter declares %s=%q", key, parts[i+1], key, declared)
}
}
// checkExtends проверяет, что объявленная база существует и принадлежит той же
// теме. Ключ документирует связь слоёв для человека — документация, которая
// врёт, хуже отсутствующей.
// checkExtends verifies that the declared base exists and belongs to the same
// topic. The key documents the tie between layers for a human — and
// documentation that lies is worse than none.
func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
if d.Front.Extends == "" {
return
@@ -74,22 +76,23 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
target := resolveExtends(s, d.Front.Extends)
if target == nil {
rep.Errorf(Spread, d.Path, at,
"extends указывает на %q, а такого файла в наборе нет", d.Front.Extends)
"extends points at %q, and the suite holds no such file", d.Front.Extends)
return
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, at,
"extends указывает на %q с темой %q, а файл несёт тему %q: слои одной темы объявляют одно имя",
"extends points at %q with topic %q, while the file carries topic %q: the layers of one topic declare one name",
d.Front.Extends, target.Front.Topic, d.Front.Topic)
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, at,
"extends указывает на %q, а это не базовый слой: у него объявлена ось", d.Front.Extends)
"extends points at %q, which is not a base layer: it declares an axis", d.Front.Extends)
}
}
// resolveExtends ищет документ по пути, записанному в extends. Путь даётся от
// директории конвенций, поэтому пробуем и его, и путь от корня набора.
// resolveExtends looks up the document at the path written in extends. The path
// is given from the conventions directory, so both it and a path from the root
// of the suite are tried.
func resolveExtends(s *suite.Suite, ref string) *doc.Document {
ref = path.Clean(strings.TrimPrefix(ref, "./"))
for _, d := range s.Docs {
@@ -100,9 +103,10 @@ func resolveExtends(s *suite.Suite, ref string) *doc.Document {
return nil
}
// checkMechanized ищет метку механизации в тексте конвенции. Механизирована
// норма или нет — свойство репозитория, а не набора, поэтому место отметки —
// локальная часть копии (META-7).
// checkMechanized looks for the mark of mechanization in the text of a
// convention. Whether a norm is mechanized is a property of a repository rather
// than of the suite, so the place of the mark is the local part of the copy
// (META-7).
func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
word := s.Vocab.MarkWord(lang.Mechanized)
if word == "" {
@@ -115,21 +119,22 @@ func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
}
if containsWord(text, word) {
rep.Errorf(Spread, d.Path, n,
"метка %s стоит в тексте конвенции: её место — запись о механизации в локальной части копии", word)
"the %s mark stands in the text of a convention: its place is the note of mechanization in the local part of the copy", word)
}
return true
})
}
// mdPathRe ловит то, что выглядит путём к файлу набора.
// mdPathRe catches what looks like a path to a file of the suite.
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
// checkCanonPaths ищет путь файла канона в тексте конвенции (META-21). В
// репозитории потребителя конвенция лежит собранной, слои одной темы — секции
// одного файла, и путь `lang/go/logging.md` там не существует: ссылка на него
// умирает при сборке, причём молча — текст остаётся связным.
// checkCanonPaths looks for the path of a canon file in the text of a
// convention (META-21). In a consumer's repository a convention lies assembled,
// the layers of one topic are sections of one file, and the path
// `lang/go/logging.md` does not exist there: a reference to it dies on assembly,
// and dies in silence — the text stays coherent.
//
// Инлайн-код здесь не вырезается: путь в бэктиках — тоже путь.
// Inline code is not cut out here: a path in backticks is still a path.
func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
@@ -141,16 +146,17 @@ func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
continue
}
rep.Errorf(Spread, d.Path, n,
"в тексте стоит путь файла канона %q: ссылаются именем темы или идентификатором правила", candidate)
"the text holds the canon file path %q: refer by the name of a topic or the identifier of a rule", candidate)
}
}
}
// checkForeignTopicPrefix ищет префикс чужой темы в блоке нормы (META-20).
// Норму правила можно исполнить, имея один этот файл: репозиторий подписывается
// на произвольное подмножество конвенций, и графа зависимостей у него нет.
// Префикс базового слоя своей темы там допустим (META-24) — собранный файл
// начинается с него независимо от выбранных языка и стека.
// checkForeignTopicPrefix looks for the prefix of a foreign topic inside a norm
// block (META-20). The norm of a rule must be executable holding this one file:
// a repository subscribes to an arbitrary subset of the conventions, and it has
// no dependency graph by construction. The prefix of the base layer of its own
// topic is allowed there (META-24) — an assembled file starts with that layer
// whatever language and stack were chosen.
func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d)
for _, r := range d.Rules {
@@ -165,13 +171,13 @@ func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) {
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, ref.Line,
"норма %s ссылается на %s из чужой темы %q: наружу смотрит только обоснование",
"the norm of %s refers to %s from the foreign topic %q: only the rationale looks outward",
r.ID(), ref.Text, target.Front.Topic)
continue
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, ref.Line,
"норма %s ссылается на %s — слой своей темы, но не базовый: в копию он попадает по манифесту, и гарантии, что он рядом, нет",
"the norm of %s refers to %s, a layer of its own topic but not the base one: that layer reaches the copy through the manifest, so there is no guarantee it stands nearby",
r.ID(), ref.Text)
}
}
+17 -16
View File
@@ -7,7 +7,7 @@ import (
"git.vakhrushev.me/av/convy/internal/suite"
)
// Suite прогоняет все проверки набора и возвращает отчёт.
// Suite runs every check of a suite and returns the report.
func Suite(s *suite.Suite) *Report {
rep := &Report{}
checkManifest(s, rep)
@@ -23,17 +23,18 @@ func Suite(s *suite.Suite) *Report {
return rep
}
// checkManifest проверяет сам манифест: форму префиксов, непересечение живого
// с выбывшим, наличие объявленных файлов и отсутствие незарегистрированных.
// checkManifest checks the manifest itself: the shape of prefixes, live and
// retired not overlapping, the declared files being present and no undeclared
// ones lying around.
func checkManifest(s *suite.Suite, rep *Report) {
m := s.Manifest
for _, key := range m.Undecoded {
rep.Warnf(Manifest, manifest.Name, 0, "ключ %s инструменту неизвестен", key)
rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key)
}
for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) {
rep.Errorf(Manifest, manifest.Name, 0, "секция [language] объявляет документ %q, а файла нет", name)
rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name)
}
}
@@ -44,7 +45,7 @@ func checkManifest(s *suite.Suite, rep *Report) {
}
if m.PrefixRetired(prefix) {
rep.Errorf(Manifest, manifest.Name, 0,
"префикс %s значится и среди живых, и среди выбывших: выбывший не выдаётся повторно", prefix)
"prefix %s is listed both live and retired: a retired one is never reissued", prefix)
}
path := m.Prefixes.Live[prefix]
byPath[path] = append(byPath[path], prefix)
@@ -53,22 +54,22 @@ func checkManifest(s *suite.Suite, rep *Report) {
if prefixes := byPath[path]; len(prefixes) > 1 {
sort.Strings(prefixes)
rep.Errorf(Manifest, manifest.Name, 0,
"на файл %q объявлено несколько префиксов (%v): префикс принадлежит файлу", path, prefixes)
"the file %q has several prefixes declared for it (%v): a prefix belongs to one file", path, prefixes)
}
}
for prefix := range m.Prefixes.Retired {
if err := manifest.ValidPrefix(prefix); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "среди выбывших: %s", err)
rep.Errorf(Manifest, manifest.Name, 0, "among the retired ones: %s", err)
}
}
for _, prefix := range sortedKeys(s.Missing) {
rep.Errorf(Manifest, manifest.Name, 0,
"префикс %s объявлен за файлом %q, а файла в наборе нет", prefix, s.Missing[prefix])
"prefix %s is assigned to the file %q, which the suite does not hold", prefix, s.Missing[prefix])
}
for _, path := range s.Unregistered {
rep.Errorf(Manifest, path, 1,
"файл записан языком конвенций, но в манифесте набора не объявлен: для набора его нет")
"the file is written in the conventions language yet not declared in the suite manifest: for the suite it does not exist")
}
for _, err := range s.Broken {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
@@ -77,18 +78,18 @@ func checkManifest(s *suite.Suite, rep *Report) {
for _, topic := range m.LiveTopics() {
if m.TopicRetired(topic) {
rep.Errorf(Manifest, manifest.Name, 0,
"тема %q значится и среди живых, и среди выбывших", topic)
"topic %q is listed both live and retired", topic)
}
if len(s.Layers(topic)) == 0 {
rep.Errorf(Manifest, manifest.Name, 0,
"тема %q объявлена живой, а слоёв у неё в наборе нет: тема живёт, пока есть хотя бы один слой", topic)
"topic %q is declared live while the suite holds no layer of it: a topic lives as long as at least one layer does", topic)
}
}
}
// checkBaseLayers проверяет, что у темы не больше одного слоя без ключей оси.
// Базовый слой единственный: он попадает в копию всегда, и второй такой
// означал бы два базовых текста в одном собранном файле.
// 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.
func checkBaseLayers(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() {
var base []string
@@ -101,7 +102,7 @@ func checkBaseLayers(s *suite.Suite, rep *Report) {
sort.Strings(base)
for _, path := range base[1:] {
rep.Errorf(Spread, path, 1,
"у темы %q больше одного слоя без ключей оси: базовый слой единственный, остальные — %v",
"topic %q holds more than one layer without axis keys: the base layer is the only one of its kind, and the candidates are %v",
topic, base)
}
}
+3 -3
View File
@@ -5,8 +5,8 @@ import (
"unicode/utf8"
)
// letterAt отвечает, стоит ли по смещению i буква или цифра. Кириллица в UTF-8
// занимает два байта, поэтому решать по одному байту нельзя.
// letterAt reports whether offset i holds a letter or a digit. Cyrillic takes
// two bytes in UTF-8, so a single byte is not enough to decide.
func letterAt(text string, i int) bool {
if i >= len(text) {
return false
@@ -15,7 +15,7 @@ func letterAt(text string, i int) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// letterBefore отвечает, стоит ли перед смещением i буква или цифра.
// letterBefore reports whether a letter or a digit stands before offset i.
func letterBefore(text string, i int) bool {
if i <= 0 {
return false