манифесты стали данными, заведён convy sync

- убраны комментарии из suite.toml и .conventions.toml: файл, который
  машина переписывает, комментарий через круг не проносит; объяснения
  ушли в README рядом, который suite init теперь заводит
- удалена текстовая правка манифеста целиком — 520 строк ручного
  лексера TOML вместе со всем классом ошибок порчи данных
- запись идёт из структур энкодером; ключ, которого инструмент не
  знает, запись останавливает, а не теряется молча
- convy sync сверяет манифест и подводит под него раскладку файлов:
  чего не хватает — собирает, что осиротело — удаляет, копию с
  локальной частью не трогает никогда
This commit is contained in:
av
2026-07-28 09:45:10 +03:00
parent b6b0976c19
commit 92bd1f463d
18 changed files with 851 additions and 1055 deletions
+45 -20
View File
@@ -11,8 +11,22 @@ import (
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/cli"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/manifest"
)
// addComponent puts one more component into the project manifest.
func addComponent(t *testing.T, root, name string, c manifest.Component) {
t.Helper()
m, err := manifest.LoadProject(root)
if err != nil {
t.Fatal(err)
}
m.Components[name] = c
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// readingGuide stands in for what a suite puts next to its copies: the short
// account of the language, naming every word the version line names.
const readingGuide = `# Как читать конвенцию
@@ -43,21 +57,44 @@ func subscribable(t *testing.T) string {
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
`)
// A topic whose only layer sits on an axis: a component of another stack
// takes nothing of it at all. The canon has such topics, and a fixture
// where every topic has a base layer would never exercise that.
run(t, root, "", false, "suite", "add", "--topic", "web-ui", "--about", "веб-UI",
"--prefix", "HTMX", "--stack", "htmx", "--title", "Веб-UI на htmx")
appendRules(t, root, "conventions/stack/htmx/web-ui.md", `
### HTMX-1. Партиал отвечает фрагментом, а не страницей
**ДОЛЖЕН.** Обработчик свопа возвращает только заменяемый фрагмент.
**ПОЧЕМУ.** Страница целиком заставляет браузер выбросить состояние формы.
`)
name := filepath.Join(root, "READING.md")
if err := os.WriteFile(name, []byte(readingGuide), 0o644); err != nil {
t.Fatal(err)
}
body := read(t, root, "suite.toml")
body = strings.Replace(body, `# reading = "READING.md"`, `reading = "READING.md"`, 1)
if err := os.WriteFile(filepath.Join(root, "suite.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
edit(t, root, func(m *manifest.Manifest) { m.Language.Reading = "READING.md" })
checkClean(t, root)
return root
}
// edit changes the suite manifest the way the tool does: through the struct,
// because the file is data and carries nothing else to preserve.
func edit(t *testing.T, root string, change func(*manifest.Manifest)) {
t.Helper()
m, err := manifest.Load(root)
if err != nil {
t.Fatal(err)
}
change(m)
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
// wired builds a project taking from that suite, and returns its root.
func wired(t *testing.T, suiteRoot string, args ...string) string {
t.Helper()
@@ -260,11 +297,7 @@ func TestAddTakesTheTopicBeforeTheFlags(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "backend/docs", "--lang", "go")
body := read(t, root, ".conventions.toml")
body += "\n[components.web]\ndir = \"web/docs\"\ntopics = []\n"
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
addComponent(t, root, "web", manifest.Component{Dir: "web/docs", Topics: []string{}})
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
if code != cli.OK {
@@ -285,11 +318,7 @@ func TestTwoComponentsMayNotShareADirectory(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
body := read(t, root, ".conventions.toml")
body += "\n[components.web]\ndir = \"docs/conventions\"\ntopics = []\n"
if err := os.WriteFile(filepath.Join(root, ".conventions.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
addComponent(t, root, "web", manifest.Component{Dir: "docs/conventions", Topics: []string{}})
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
@@ -340,11 +369,7 @@ func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
if err := os.Rename(filepath.Join(suiteRoot, "READING.md"), filepath.Join(apart, "READING.md")); err != nil {
t.Fatal(err)
}
body := read(t, suiteRoot, "suite.toml")
body = strings.Replace(body, `reading = "READING.md"`, "source = \"../language\"\nreading = \"READING.md\"", 1)
if err := os.WriteFile(filepath.Join(suiteRoot, "suite.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
edit(t, suiteRoot, func(m *manifest.Manifest) { m.Language.Source = "../language" })
checkClean(t, suiteRoot)
// A check that did not run says so: silence would read as a check passed.