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