манифесты стали данными, заведён 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
+215
View File
@@ -0,0 +1,215 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy sync makes the files agree with the manifest. The manifest is the
// truth: it says which components exist, where they write and what each takes,
// and everything under those directories follows from that.
//
// It divides from pull by what it is about. pull is about the contents of a
// copy — it takes the text of every subscription afresh, and the diff it leaves
// is the point of running it. sync is about the set of files: what the manifest
// calls for and is not there gets assembled, what is there and nothing calls
// for gets reported and, when nothing of the repository is in it, removed.
//
// A copy carrying a local part is never removed. Below the marker is the one
// thing in the directory that exists nowhere else, and a command that tidies up
// has no business deciding it is spent.
func runSync(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy sync", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
forComponent := fs.String("for", "", "component to bring in line; every one of them by default")
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
if err := fs.Parse(args); err != nil {
return Usage
}
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
return code
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
names, code := components(env, o.Manifest, *forComponent)
if code != OK {
return code
}
problems := validateManifest(o, names)
for _, p := range problems {
fmt.Fprintf(env.Err, "%s\n", p)
}
if len(problems) > 0 {
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
return Failed
}
changed, left := 0, 0
for i, name := range names {
c := o.Manifest.Components[name]
if i > 0 {
fmt.Fprintln(env.Out)
}
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
n, stuck, code := syncComponent(env, o, c, *dry)
if code != OK {
return code
}
changed += n
left += stuck
}
fmt.Fprintln(env.Out)
switch {
case *dry && changed > 0:
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
case changed > 0:
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
case left == 0:
fmt.Fprintln(env.Out, "the layout already follows the manifest")
}
if left > 0 {
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
return Failed
}
return OK
}
// validate checks the manifest against itself and against the suite. Everything
// wrong is reported at once: being sent back one line at a time is the worst way
// to learn what a file wants.
func validateManifest(o *opened, names []string) []string {
var out []string
if err := distinctDirs(o.Manifest); err != nil {
out = append(out, err.Error())
}
for _, name := range names {
c := o.Manifest.Components[name]
if c.Dir == "" {
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
}
if len(c.Lang) > 1 {
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
name, strings.Join(c.Lang, ", ")))
}
seen := make(map[string]bool, len(c.Topics))
for _, topic := range c.Topics {
switch {
case seen[topic]:
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
case o.Suite.Manifest.TopicRetired(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
case !o.Suite.Manifest.TopicLive(topic):
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
default:
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
}
}
seen[topic] = true
}
}
return out
}
// syncComponent brings one directory in line. It returns how many files moved
// and how many it would not touch.
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
for _, topic := range c.Topics {
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
continue
}
changed++
if dry {
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
continue
}
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
}
orphans, err := orphaned(o.Root, c)
if err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
for _, orphan := range orphans {
switch {
case orphan.local:
left++
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
orphan.path, orphan.topic)
case dry:
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
default:
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
fmt.Fprintln(env.Err, err)
return changed, left, Failed
}
changed++
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
}
}
if !dry {
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
if err != nil {
fmt.Fprintf(env.Err, " %s\n", err)
return changed, left, Failed
}
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
}
return changed, left, OK
}
// orphan is a copy in a component directory that the manifest does not call for.
type orphan struct {
path string
topic string
local bool
}
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
// the origin key: README.md belongs to the repository, READING.md belongs to the
// suite, and a file whose origin was taken away has become a document of the
// repository — none of the three is anyone's to remove.
func orphaned(root string, c manifest.Component) ([]orphan, error) {
docs, broken := copies(root, c.Dir)
if len(broken) > 0 {
return nil, broken[0]
}
var out []orphan
for _, d := range docs {
if c.Subscribed(d.Front.Origin) {
continue
}
local := ""
if at := d.Marker(); at > 0 {
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
}
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
}
return out, nil
}