проектные команды и ссылки на источник

- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
av
2026-07-27 20:42:18 +03:00
parent 4615de6e86
commit 23d88c4048
27 changed files with 3009 additions and 57 deletions
+168
View File
@@ -0,0 +1,168 @@
package cli
import (
"flag"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy add does two things at once, and they are one thing: it writes the
// subscription into the manifest and assembles the file. A subscription that
// left no file behind, or a file nothing subscribed to, is the state the model
// has no name for.
func runAdd(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy add", 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 assemble for; needed when there is more than one")
topicFlag := fs.String("topic", "", "topic to subscribe to; may also be given as the first argument")
// The topic is taken off the front before the flags are parsed. The flag
// package stops at the first argument that is not a flag, so "convy add
// time --for backend" — the form the model writes — would otherwise lose
// the component silently.
topic := ""
rest := args
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
topic, rest = rest[0], rest[1:]
}
if err := fs.Parse(rest); err != nil {
return Usage
}
if *topicFlag != "" {
if topic != "" && topic != *topicFlag {
fmt.Fprintf(env.Err, "the topic is named twice and differently: %q and %q\n", topic, *topicFlag)
return Usage
}
topic = *topicFlag
}
if rest := fs.Args(); len(rest) > 0 {
if topic != "" && topic != rest[0] {
fmt.Fprintf(env.Err, "the topic is named twice and differently: %q and %q\n", topic, rest[0])
return Usage
}
topic = rest[0]
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
if err := distinctDirs(o.Manifest); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
name, c, code := componentOf(env, o.Manifest, *forComponent)
if code != OK {
return code
}
if topic == "" {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy add without arguments asks which topic, and there is no terminal to ask on; name the topic as an argument")
return Usage
}
answer, err := newDialogue(env).ask(topicField(o, c))
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return Usage
}
topic = answer
}
if !o.Suite.Manifest.TopicLive(topic) {
if o.Suite.Manifest.TopicRetired(topic) {
fmt.Fprintf(env.Err, "the suite has retired the topic %q: %s\n", topic, o.Suite.Manifest.Topics.Retired[topic])
return Usage
}
fmt.Fprintf(env.Err, "the suite declares no topic %q; it declares: %s\n", topic, strings.Join(o.Suite.Manifest.LiveTopics(), ", "))
return Usage
}
if c.Subscribed(topic) {
fmt.Fprintf(env.Err, "the component %q is subscribed to %q already; convy pull reassembles it\n", name, topic)
return Usage
}
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
// The manifest is written after the file: a subscription recorded against
// an assembly that failed would send the next pull looking for a copy that
// was never made.
source, err := os.ReadFile(o.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
updated, err := manifest.AddToList(source, "components."+name, "topics", topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(o.Manifest.Path, updated, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
if err != nil {
fmt.Fprintf(env.Err, "warning: %s\n", err)
}
fmt.Fprintf(env.Out, "\n%s → %s\n", topic, made.Path)
for _, layer := range made.Layers {
fmt.Fprintf(env.Out, " %s\n", layer)
}
if guide != "" {
fmt.Fprintf(env.Out, "\n%s refreshed\n", guide)
}
fmt.Fprintf(env.Out, "subscribed the component %q in %s\n", name, manifest.ProjectName)
return OK
}
// topicField offers the topics the component has not taken yet.
func topicField(o *opened, c manifest.Component) Field {
var free []string
for _, t := range o.Suite.Manifest.LiveTopics() {
if !c.Subscribed(t) {
free = append(free, t)
}
}
return Field{
Flag: "topic",
Ask: "Topic",
Hint: "A topic is taken whole: the file gathers every layer of it the component fits. " + describeTopics(o, free),
Options: free,
Check: func(v string) error {
if !o.Suite.Manifest.TopicLive(v) {
return fmt.Errorf("the suite declares no topic %q", v)
}
if c.Subscribed(v) {
return fmt.Errorf("this component is subscribed to %q already", v)
}
return nil
},
}
}
func describeTopics(o *opened, free []string) string {
if len(free) == 0 {
return "The component is subscribed to everything the suite has."
}
parts := make([]string, 0, len(free))
for _, t := range free {
parts = append(parts, fmt.Sprintf("%s — %s", t, o.Suite.Manifest.Topics.Live[t]))
}
return "On offer: " + strings.Join(parts, "; ") + "."
}
+142
View File
@@ -0,0 +1,142 @@
package cli
import (
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy check stays at the top level and reaches for no suite. The form of a
// rule is one and the same, the local rules of the repository on X prefixes are
// written by that same form, and checking what lies here has to work without a
// network and without knowing where the copies came from.
func runCheck(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy check", 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 check; every one of them by default")
quiet := fs.Bool("quiet", false, "print findings only")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := projectRoot(env, *root)
if code != OK {
return code
}
m, err := manifest.LoadProject(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
names, code := components(env, m, *forComponent)
if code != OK {
return code
}
var docs []*doc.Document
var broken []error
for _, name := range names {
found, errs := copies(dir, m.Components[name].Dir)
docs = append(docs, found...)
broken = append(broken, errs...)
}
rep := check.Copies(docs)
for _, err := range broken {
fmt.Fprintf(env.Err, "%s\n", err)
}
printCopyReport(env.Out, rep, len(docs), len(names), *quiet)
if rep.Errors() > 0 || len(broken) > 0 {
return Failed
}
return OK
}
// copies collects the assembled conventions of one component directory.
//
// What is a copy is decided by the origin key rather than by the name of the
// file: README.md belongs to the repository, READING.md belongs to the suite,
// and a file whose origin key was taken away has become a document of the
// repository — none of the three answers to the form of a rule.
func copies(root, dir string) ([]*doc.Document, []error) {
var docs []*doc.Document
var broken []error
base := filepath.Join(root, filepath.FromSlash(dir))
err := filepath.WalkDir(base, func(name string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
return nil
}
rel, err := filepath.Rel(root, name)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
d, err := doc.Load(rel, name)
if err != nil {
broken = append(broken, err)
return nil
}
if d.Front.Origin == "" {
return nil
}
docs = append(docs, d)
return nil
})
if err != nil && !os.IsNotExist(err) {
broken = append(broken, fmt.Errorf("walking %s: %w", dir, err))
}
sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path })
return docs, broken
}
func printCopyReport(w io.Writer, rep *check.Report, files, comps int, quiet bool) {
findings := rep.Findings()
current := ""
for _, f := range findings {
if f.Path != current {
if current != "" {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%s\n", f.Path)
current = f.Path
}
where := ""
if f.Line > 0 {
where = fmt.Sprintf(":%d", f.Line)
}
fmt.Fprintf(w, " %s%s %s [%s]\n", f.Severity, where, f.Msg, f.Family)
}
if quiet {
return
}
if len(findings) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "project: %s in %s\n", plural(files, "file"), plural(comps, "component"))
switch {
case rep.Errors() > 0:
fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings())
case rep.Warnings() > 0:
fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
case files == 0:
fmt.Fprintf(w, "nothing to check: no file carries an origin key; convy pull assembles the copies\n")
default:
fmt.Fprintf(w, "the copies hold the form; everything below %s is the repository's own\n", project.LocalMarker)
}
}
+15 -7
View File
@@ -48,9 +48,16 @@ func Run(env Env, args []string) ExitCode {
switch args[0] {
case "suite":
return runSuite(env, args[1:])
case "add", "pull", "list", "check":
fmt.Fprintf(env.Err, "the %q command is not implemented yet\n", args[0])
return Usage
case "init":
return runInit(env, args[1:])
case "add":
return runAdd(env, args[1:])
case "pull":
return runPull(env, args[1:])
case "list":
return runList(env, args[1:])
case "check":
return runCheck(env, args[1:])
case "help", "-h", "--help":
usage(env.Out)
return OK
@@ -90,10 +97,11 @@ func usage(w io.Writer) {
fmt.Fprint(w, `convy — tending development conventions.
In a project:
convy add <topic> subscribe and assemble (not implemented)
convy pull reassemble what is subscribed (not implemented)
convy list what is wired up and available (not implemented)
convy check check the form of what is here (not implemented)
convy init wire up conventions: the source and the first component
convy add <topic> subscribe and assemble
convy pull reassemble what is subscribed
convy list what is wired up and what else the suite has
convy check check the form of what is here
In a suite:
convy suite init start a suite: a directory and a manifest
+192
View File
@@ -0,0 +1,192 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// projectSkeleton is what a project manifest starts as. It is written as text
// with its comments in place for the same reason the suite manifest is: the
// file is read far more often than a tool touches it, and what a component is
// for is not deducible from three keys.
const projectSkeleton = `# What this repository takes from a conventions suite.
#
# Two manifests exist in the model, each named after what it describes:
# suite.toml in a suite describes the suite itself; .conventions.toml here
# describes the subscription — where the copies come from and who takes what.
#
# Copies are committed. Nothing is fetched on the fly, and the answer to "what
# did it look like last time" is given by git rather than by a lock file.
# Where the copies come from: a path on disk, relative to this file or absolute,
# or a git repository over http or https. A trailing #branch, #tag or #commit
# pins a revision.
source = "%s"
# ─── Components ─────────────────────────────────────────────────────────────
#
# A component is a region of the repository where every selected layer holds at
# once: one language, one set of tools, one kind of application. Each one has
# its own directory, and the directories differ — that is the only thing that
# tells two copies of one topic apart.
#
# lang and stack choose the layers: a layer travels when the axis it declares is
# among them, and a layer declaring no axis travels always. topics is the
# subscription itself, by the names the suite gives its topics.
`
func runInit(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy init", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the project; the current directory by default")
from := fs.String("source", "", "reference to the suite: a path on disk or a git repository")
component := fs.String("component", "", "name of the first component")
dir := fs.String("dir", "", "directory the copies of that component go into")
langAxis := fs.String("lang", "", "language of the component")
stackAxis := fs.String("stack", "", "stack of the component, comma-separated")
if err := fs.Parse(args); err != nil {
return Usage
}
where := *root
if where == "" {
where = env.Dir
}
if exists(filepath.Join(where, manifest.ProjectName)) {
fmt.Fprintf(env.Err, "%s already holds %s: the conventions are wired up already\n", where, manifest.ProjectName)
fmt.Fprintln(env.Err, "convy add subscribes to one more topic")
return Usage
}
given := map[string]string{
"source": *from, "component": *component, "dir": *dir,
"lang": *langAxis, "stack": *stackAxis,
}
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy init without arguments asks questions, and there is no terminal to ask on; pass --source, --component and --dir")
return Usage
}
if err := askAll(env, initFields(), given); err != nil {
return Usage
}
} else if err := resolve(initFields(), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
// The suite is reached before the manifest is written. A manifest naming a
// suite nobody can reach passes every check the tool has and helps no one.
ref, err := source.Parse(given["source"])
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
tree, err := source.Open(ref, where)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
defer tree.Close()
s, err := suite.Load(tree.Dir())
if err != nil {
fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err))
return Failed
}
entries := [][2]string{{"dir", quote(given["dir"])}}
if list := split(given["lang"]); len(list) > 0 {
entries = append(entries, [2]string{"lang", array(list)})
}
if list := split(given["stack"]); len(list) > 0 {
entries = append(entries, [2]string{"stack", array(list)})
}
entries = append(entries, [2]string{"topics", "[]"})
body, err := manifest.AddTable([]byte(fmt.Sprintf(projectSkeleton, given["source"])),
"components."+given["component"], entries)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.MkdirAll(where, 0o755); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
name := filepath.Join(where, manifest.ProjectName)
if err := os.WriteFile(name, body, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\ncreated %s\n", name)
fmt.Fprintf(env.Out, "the suite speaks %s, conventions language version %d\n",
s.Manifest.Language.Lang, s.Manifest.Language.Version)
topics := s.Manifest.LiveTopics()
if len(topics) > 0 {
fmt.Fprintf(env.Out, "\n%s to take from:\n", plural(len(topics), "topic"))
for _, t := range topics {
fmt.Fprintf(env.Out, " %-20s %s\n", t, s.Manifest.Topics.Live[t])
}
}
fmt.Fprint(env.Out, `
next:
convy add <topic> subscribe and assemble
convy list what is wired up and what else is there
`)
return OK
}
func initFields() []Field {
return []Field{{
Flag: "source",
Ask: "Where the copies come from",
Hint: "A path to the suite on disk — relative to this repository or absolute — or a git repository over http or https. A trailing #branch, #tag or #commit pins a revision.",
}, {
Flag: "component",
Ask: "Name of the component",
Hint: "A region of the repository where all the chosen layers hold at once: one language, one set of tools. The name is not internal — it is how the tool answers what it assembled and where.",
Check: func(v string) error {
if !nameRe.MatchString(v) {
return fmt.Errorf("a component is named by a plain identifier: letters, digits, a dash")
}
return nil
},
}, {
Flag: "dir",
Ask: "Directory of the copies",
Hint: "Where the assembled files go. Every component has its own, and two components never share one: the copies of a topic would collide by name.",
Default: "docs/conventions",
}, {
Flag: "lang",
Ask: "Language of the component",
Hint: "Chooses the language layers. Empty when the suite is flat and has no axes at all.",
Optional: true,
}, {
Flag: "stack",
Ask: "Stack of the component",
Hint: "Chooses the stack layers — the storage, the transport, the tools. Several are allowed, comma-separated: sqlite and postgres hold together, being different tables of one service.",
Optional: true,
}}
}
func quote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
func array(values []string) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = quote(v)
}
return "[" + strings.Join(parts, ", ") + "]"
}
+108
View File
@@ -0,0 +1,108 @@
package cli
import (
"flag"
"fmt"
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy list answers two questions in one view: what this repository takes and
// what the suite has that it does not. The second half is the reason the
// command reaches the suite at all — a listing of the manifest alone is the
// manifest, and reading it needs no tool.
func runList(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy list", 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 show; every one of them by default")
if err := fs.Parse(args); err != nil {
return Usage
}
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
}
fmt.Fprintf(env.Out, "source %s\n", o.Manifest.Source)
fmt.Fprintf(env.Out, "%s, language version %d (%s)\n",
plural(len(o.Suite.Manifest.LiveTopics()), "topic"),
o.Suite.Manifest.Language.Version, o.Suite.Manifest.Language.Lang)
for _, name := range names {
c := o.Manifest.Components[name]
fmt.Fprintf(env.Out, "\n%s — %s%s\n", name, c.Dir, axisOf(c))
listComponent(env, o, c)
}
return OK
}
func listComponent(env Env, o *opened, c manifest.Component) {
if len(c.Topics) == 0 {
fmt.Fprintln(env.Out, " subscribed to nothing yet")
}
for _, topic := range c.Topics {
if !o.Suite.Manifest.TopicLive(topic) {
fmt.Fprintf(env.Out, " %-20s the suite declares no such topic any more\n", topic)
continue
}
taken, _ := o.Suite.Assemble(topic, project.Axis(c))
file := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
mark := " "
if !exists(filepath.Join(o.Root, filepath.FromSlash(file))) {
mark = "!"
}
fmt.Fprintf(env.Out, "%s %-20s %-16s %s\n", mark, topic, plural(len(taken), "layer"),
o.Suite.Manifest.Topics.Live[topic])
if mark == "!" {
fmt.Fprintf(env.Out, " %-20s subscribed, and no file: convy pull assembles it\n", "")
}
}
var free []string
for _, topic := range o.Suite.Manifest.LiveTopics() {
if !c.Subscribed(topic) {
free = append(free, topic)
}
}
if len(free) == 0 {
return
}
fmt.Fprintln(env.Out, "\n not taken:")
for _, topic := range free {
taken, _ := o.Suite.Assemble(topic, project.Axis(c))
if len(taken) == 0 {
fmt.Fprintf(env.Out, "· %-20s %-16s %s\n", topic, "no layer fits",
o.Suite.Manifest.Topics.Live[topic])
continue
}
fmt.Fprintf(env.Out, "· %-20s %-16s %s\n", topic, plural(len(taken), "layer"),
o.Suite.Manifest.Topics.Live[topic])
}
}
// axisOf describes the axis of a component the way the suite writes it.
func axisOf(c manifest.Component) string {
var parts []string
if len(c.Lang) > 0 {
parts = append(parts, "lang="+strings.Join(c.Lang, ","))
}
if len(c.Stack) > 0 {
parts = append(parts, "stack="+strings.Join(c.Stack, ","))
}
if len(parts) == 0 {
return ""
}
return " (" + strings.Join(parts, " ") + ")"
}
+167
View File
@@ -0,0 +1,167 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
// A project command works against two levels at once: the manifest lying in the
// repository and the suite it names. Reaching the suite costs a clone when the
// reference is a git one, so it is done once per command and released at the
// end — nothing of the suite is left lying about in the project.
// opened is a project together with the levels above it.
type opened struct {
Root string
Manifest *manifest.Project
Suite *suite.Suite
// LangRoot is where the documents about the language lie. It is the root
// of the suite while the language has no repository of its own.
LangRoot string
trees []*source.Tree
}
// Close releases whatever the opening fetched.
func (o *opened) Close() {
for _, t := range o.trees {
t.Close()
}
}
// openProject finds the project, resolves its source and loads the suite.
func openProject(env Env, root string) (*opened, ExitCode) {
dir, code := projectRoot(env, root)
if code != OK {
return nil, code
}
m, err := manifest.LoadProject(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Usage
}
for _, key := range m.Undecoded {
fmt.Fprintf(env.Err, "warning: %s: the key %s is unknown to the tool\n", m.Path, key)
}
if m.Source == "" {
fmt.Fprintf(env.Err, "%s names no source: a copy comes from a suite, and the manifest is where the suite is named\n", m.Path)
return nil, Usage
}
ref, err := source.Parse(m.Source)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Usage
}
tree, err := source.Open(ref, dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return nil, Failed
}
o := &opened{Root: dir, Manifest: m, LangRoot: tree.Dir(), trees: []*source.Tree{tree}}
o.Suite, err = suite.Load(tree.Dir())
if err != nil {
fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err))
o.Close()
return nil, Failed
}
// The language is a level of its own, and a suite may keep its documents
// apart from itself. While it does not, the suite is where they lie.
if spec := o.Suite.Manifest.Language.Source; spec != "" {
ref, err := source.Parse(spec)
if err != nil {
o.Close()
fmt.Fprintf(env.Err, "%s: [language] source: %s\n", o.Suite.Manifest.Path, err)
return nil, Usage
}
langTree, err := source.Open(ref, tree.Dir())
if err != nil {
o.Close()
fmt.Fprintln(env.Err, err)
return nil, Failed
}
o.trees = append(o.trees, langTree)
o.LangRoot = langTree.Dir()
}
return o, OK
}
// projectRoot finds the manifest of the project. A project command typed inside
// a suite does not do anything at a guess: it says where it is and names the
// command of that level.
func projectRoot(env Env, given string) (string, ExitCode) {
if given != "" {
return given, OK
}
found, err := manifest.FindProject(env.Dir)
if err == nil {
return found, OK
}
if _, suiteErr := manifest.Find(env.Dir); suiteErr == nil {
fmt.Fprintf(env.Err, "this is a conventions suite, not a project that takes copies: %s lies here, %s does not\n", manifest.Name, manifest.ProjectName)
fmt.Fprintln(env.Err, "the commands of a suite are under convy suite")
return "", Usage
}
fmt.Fprintf(env.Err, "not a project with conventions: no %s here or above\n", manifest.ProjectName)
fmt.Fprintln(env.Err, "convy init wires one up")
return "", Usage
}
// componentOf resolves the --for flag against the manifest.
func componentOf(env Env, m *manifest.Project, name string) (string, manifest.Component, ExitCode) {
got, c, err := m.Only(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return "", manifest.Component{}, Usage
}
if c.Dir == "" {
fmt.Fprintf(env.Err, "the component %q names no dir, and a copy has to be written somewhere\n", got)
return "", manifest.Component{}, Usage
}
return got, c, OK
}
// components picks the components a command works on: the one named by --for,
// or every one of them.
func components(env Env, m *manifest.Project, name string) ([]string, ExitCode) {
if name != "" {
if _, ok := m.Components[name]; !ok {
fmt.Fprintf(env.Err, "the project declares no component %q\n", name)
return nil, Usage
}
return []string{name}, OK
}
if len(m.Components) == 0 {
fmt.Fprintf(env.Err, "%s declares no component, and a copy is assembled for a component\n", m.Path)
return nil, Usage
}
return m.Names(), OK
}
// distinctDirs checks that no two components write into the same directory.
// Two copies of one topic would otherwise collide by name, and that is an error
// of the manifest rather than a reason to rename files.
func distinctDirs(m *manifest.Project) error {
seen := make(map[string]string)
for _, name := range m.Names() {
dir := 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)
}
seen[dir] = name
}
return nil
}
// exists reports whether a path is there.
func exists(name string) bool {
_, err := os.Stat(name)
return err == nil
}
+387
View File
@@ -0,0 +1,387 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
// 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 = `# Как читать конвенцию
Слова толкуются так, и только когда написаны заглавными.
| Слово | Значение |
|---|---|
| ДОЛЖЕН | требование |
| НЕ ДОЛЖЕН | запрет |
| СЛЕДУЕТ | рекомендация |
| НЕ СЛЕДУЕТ | рекомендация против |
| ДОПУСКАЕТСЯ | разрешение |
| ПОЧЕМУ | обоснование |
| ПРИМЕРЫ | иллюстрация |
| МЕХАНИЗИРОВАНО | чем проверяется |
| СНЯТО | заглушка снятого правила |
`
// subscribable is a suite a project can take from: the fixture of the retire
// tests plus the guide that travels next to the copies.
func subscribable(t *testing.T) string {
t.Helper()
root := retirable(t)
appendRules(t, root, "conventions/lang/go/time.md", `
### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из `+"`store.Now()`"+`.
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат.
`)
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)
}
checkClean(t, root)
return root
}
// wired builds a project taking from that suite, and returns its root.
func wired(t *testing.T, suiteRoot string, args ...string) string {
t.Helper()
root := filepath.Join(t.TempDir(), "app")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
all := append([]string{"init", "--source", suiteRoot}, args...)
if code, out := run(t, root, "", false, all...); code != cli.OK {
t.Fatalf("convy init returned %d: %s", code, out)
}
return root
}
func TestInitAddAndPullBuildACopyOutOfTheLayers(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
code, out := run(t, root, "", false, "add", "time")
if code != cli.OK {
t.Fatalf("convy add returned %d: %s", code, out)
}
if !strings.Contains(out, "docs/conventions/time.md") {
t.Errorf("the assembled file is not named:\n%s", out)
}
body := read(t, root, "docs/conventions/time.md")
for _, want := range []string{
"origin: time",
"### TIME-1.",
"#### GTIM-1.",
"<!-- conv:local -->",
} {
if !strings.Contains(body, want) {
t.Errorf("the copy lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "prefix: TIME") {
t.Errorf("the front matter of a layer travelled into the copy:\n%s", body)
}
// The guide to reading a rule belongs to the suite and travels with the
// copies; README.md in the same directory belongs to the repository.
if guide := read(t, root, "docs/conventions/READING.md"); !strings.Contains(guide, "ДОПУСКАЕТСЯ") {
t.Errorf("the reading guide did not travel:\n%s", guide)
}
if manifest := read(t, root, ".conventions.toml"); !strings.Contains(manifest, `topics = ["time"]`) {
t.Errorf("the subscription was not written:\n%s", manifest)
}
if code, out := run(t, root, "", false, "check"); code != cli.OK {
t.Fatalf("checking what the tool assembled returned %d: %s", code, out)
}
}
// The whole point of the marker: what the repository wrote survives, what the
// suite wrote is replaced.
func TestPullKeepsTheLocalPartAndReplacesTheRest(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 = strings.Replace(body, "**ДОЛЖЕН.** Момент времени", "**ДОЛЖЕН.** Правка выше маркера", 1)
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "pull")
if code != cli.OK {
t.Fatalf("convy pull returned %d: %s", code, out)
}
if !strings.Contains(out, "local part kept") {
t.Errorf("the pull did not say the local part survived:\n%s", out)
}
body = read(t, root, "docs/conventions/time.md")
if strings.Contains(body, "Правка выше маркера") {
t.Errorf("an edit above the marker survived, and it is declared not to:\n%s", body)
}
if !strings.Contains(body, "МЕХАНИЗИРОВАНО: `internal/archrules`") {
t.Errorf("the local part was lost:\n%s", body)
}
}
// A layer travels when the axis it declares agrees with the component; the base
// layer travels always.
func TestAComponentTakesOnlyTheLayersThatFitIt(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "web", "--dir", "web/docs/conventions", "--lang", "javascript")
run(t, root, "", false, "add", "time")
body := read(t, root, "web/docs/conventions/time.md")
if !strings.Contains(body, "### TIME-1.") {
t.Errorf("the base layer did not travel:\n%s", body)
}
if strings.Contains(body, "GTIM-1") {
t.Errorf("a go layer travelled into a javascript component:\n%s", body)
}
}
func TestProjectCommandsRefuseWhatTheyCannotDo(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
run(t, root, "", false, "add", "time")
cases := []struct {
name string
args []string
want string
}{{
name: "a topic the suite does not declare",
args: []string{"add", "billing"},
want: "declares no topic",
}, {
name: "a topic taken twice",
args: []string{"add", "time"},
want: "subscribed to",
}, {
name: "a component that is not there",
args: []string{"pull", "--for", "mobile"},
want: "no component",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, tc.args...)
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
}
// A file that lost its origin key has become a document of the repository, and
// assembly has no business overwriting it.
func TestPullDoesNotOverwriteWhatIsNoLongerACopy(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 = strings.Replace(body, "---\norigin: time\n---\n\n", "", 1)
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
t.Fatalf("the pull overwrote a document of the repository:\n%s", out)
}
if !strings.Contains(out, "no origin key") {
t.Errorf("the refusal does not say why:\n%s", out)
}
if got := read(t, root, "docs/conventions/time.md"); strings.Contains(got, "origin: time") {
t.Errorf("the file was rewritten anyway:\n%s", got)
}
}
// The context is read off the manifest lying next to you, and nothing is done
// at a guess.
func TestAProjectCommandInsideASuiteSaysWhereItIs(t *testing.T) {
suiteRoot := subscribable(t)
code, out := run(t, suiteRoot, "", false, "pull")
if code == cli.OK {
t.Fatalf("convy pull ran inside a suite:\n%s", out)
}
if !strings.Contains(out, "convy suite") {
t.Errorf("the refusal does not point at the commands of a suite:\n%s", out)
}
}
func TestListShowsWhatIsTakenAndWhatIsNot(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, "list")
if code != cli.OK {
t.Fatalf("convy list returned %d: %s", code, out)
}
for _, want := range []string{"backend — docs/conventions", "lang=go", "time", "2 layers", "not taken", "logging"} {
if !strings.Contains(out, want) {
t.Errorf("the listing lacks %q:\n%s", want, out)
}
}
}
// The topic stands in front of the flags, and the flag package stops at the
// first argument that is not one — so "convy add time --for backend", the form
// the model writes, has to keep the component rather than drop it in silence.
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)
}
code, out := run(t, root, "", false, "add", "logging", "--for", "web")
if code != cli.OK {
t.Fatalf("convy add returned %d: %s", code, out)
}
if !strings.Contains(out, "web/docs/logging.md") {
t.Errorf("the copy did not land in the named component:\n%s", out)
}
if manifest := read(t, root, ".conventions.toml"); !strings.Contains(manifest, `topics = ["logging"]`) {
t.Errorf("the subscription went to the wrong component:\n%s", manifest)
}
}
// Distinct directories are the only thing that tells two copies of one topic
// apart, so sharing one is an error of the manifest rather than a reason to
// rename files.
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)
}
code, out := run(t, root, "", false, "pull")
if code == cli.OK {
t.Fatalf("two components wrote into one directory:\n%s", out)
}
if !strings.Contains(out, "share the directory") {
t.Errorf("the refusal does not say what collides:\n%s", out)
}
}
// Bare, a project command asks; that mode is for a person, and the one with
// flags is for agents and scripts.
func TestAddAsksWhichTopicWhenToldNothing(t *testing.T) {
suiteRoot := subscribable(t)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
code, out := run(t, root, "time\n", true, "add")
if code != cli.OK {
t.Fatalf("the dialogue returned %d: %s", code, out)
}
if !strings.Contains(out, "Topic") || !strings.Contains(out, "время") {
t.Errorf("the question carries no hint about what is on offer:\n%s", out)
}
if !strings.Contains(out, "docs/conventions/time.md") {
t.Errorf("the dialogue assembled nothing:\n%s", out)
}
// Without a terminal the same bare command refuses instead of blocking on
// an answer nobody is there to give.
code, out = run(t, root, "", false, "add")
if code == cli.OK {
t.Fatalf("a bare command went through with no terminal:\n%s", out)
}
if !strings.Contains(out, "no terminal") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// The language is a level of its own, and a suite may keep its documents apart.
// Nothing else changes: the guide still travels next to the copies.
func TestTheLanguageMayLiveApartFromTheSuite(t *testing.T) {
suiteRoot := subscribable(t)
apart := filepath.Join(filepath.Dir(suiteRoot), "language")
if err := os.MkdirAll(apart, 0o755); err != nil {
t.Fatal(err)
}
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)
}
checkClean(t, suiteRoot)
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
if code, out := run(t, root, "", false, "add", "time"); code != cli.OK {
t.Fatalf("convy add returned %d: %s", code, out)
}
if guide := read(t, root, "docs/conventions/READING.md"); !strings.Contains(guide, "ДОПУСКАЕТСЯ") {
t.Errorf("the guide did not come from the level it lives on:\n%s", guide)
}
}
// The rules of the repository take a prefix on X and live below the marker. One
// standing above it would be wiped by the next pull, and saying so afterwards
// is too late.
func TestCheckCatchesALocalRuleAboveTheMarker(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")
local := "\n### XTIM-1. Часы в тестах замораживаются\n\n" +
"**ДОЛЖЕН.** Тест берёт время у подменённого `store.Now`.\n\n" +
"**ПОЧЕМУ.** Плавающее время делает падение теста невоспроизводимым.\n"
if err := os.WriteFile(name, []byte(body+local), 0o644); err != nil {
t.Fatal(err)
}
if code, out := run(t, root, "", false, "check"); code != cli.OK {
t.Fatalf("a local rule below the marker was turned down: %d\n%s", code, out)
}
above := strings.Replace(body, "<!-- conv:local -->", local+"\n<!-- conv:local -->", 1)
if err := os.WriteFile(name, []byte(above), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "check")
if code == cli.OK {
t.Fatalf("a rule of the repository above the marker went unnoticed:\n%s", out)
}
if !strings.Contains(out, "would wipe it") {
t.Errorf("the finding does not say what is at stake:\n%s", out)
}
}
+104
View File
@@ -0,0 +1,104 @@
package cli
import (
"flag"
"fmt"
"git.vakhrushev.me/av/convy/internal/project"
)
// convy pull reassembles what the manifest lists. It never reports what
// changed: after a pull that is shown by git diff, and the decision to accept,
// to fix or to roll back is taken by a person before the commit. A second
// mechanism for comparing files, standing next to git, would answer the same
// question worse.
func runPull(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy pull", 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 reassemble; every one of them by default")
if err := fs.Parse(args); err != nil {
return Usage
}
o, code := openProject(env, *root)
if code != OK {
return code
}
defer o.Close()
if err := distinctDirs(o.Manifest); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
names, code := components(env, o.Manifest, *forComponent)
if code != OK {
return code
}
failed := 0
written := 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)
if c.Dir == "" {
fmt.Fprintln(env.Err, " the component names no dir, and a copy has to be written somewhere")
failed++
continue
}
if len(c.Topics) == 0 {
fmt.Fprintln(env.Out, " subscribed to nothing yet")
}
// The path column is sized to the component rather than guessed: a
// name that overruns a fixed width breaks every row below it.
width := len(c.Dir) + 1 + len(project.ReadingName)
for _, topic := range c.Topics {
width = max(width, len(c.Dir)+len(topic)+4)
}
for _, topic := range c.Topics {
made, err := project.Assemble(o.Suite, o.Root, c, topic)
if err != nil {
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
failed++
continue
}
written++
fmt.Fprintf(env.Out, " %-*s %s%s\n", width, made.Path, plural(len(made.Layers), "layer"), kept(made))
}
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)
failed++
continue
}
fmt.Fprintf(env.Out, " %-*s the guide to reading a rule\n", width, guide)
}
fmt.Fprintf(env.Out, "\n%s assembled", plural(written, "file"))
if failed > 0 {
fmt.Fprintf(env.Out, ", %s\n", plural(failed, "failure"))
return Failed
}
fmt.Fprintln(env.Out)
fmt.Fprintln(env.Out, "git diff says what changed")
return OK
}
// kept notes that a local part was carried over, because that is the one thing
// a reassembly could have destroyed and did not.
func kept(c project.Copy) string {
switch {
case c.Created:
return ", new"
case c.Kept:
return ", local part kept"
}
return ""
}
+16 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
@@ -22,7 +23,7 @@ func runSuiteList(env Env, args []string) ExitCode {
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
topic := fs.String("topic", "", "show one topic only")
langAxis := fs.String("lang", "", "language of the component, to show what it would take")
stackAxis := fs.String("stack", "", "stack of the component, to show what it would take")
stackAxis := fs.String("stack", "", "stack of the component, comma-separated, to show what it would take")
retired := fs.Bool("retired", false, "show the retired names instead of the live ones")
if err := fs.Parse(args); err != nil {
return Usage
@@ -60,7 +61,7 @@ func runSuiteList(env Env, args []string) ExitCode {
}
selecting := *langAxis != "" || *stackAxis != ""
component := suite.Component{Lang: *langAxis, Stack: *stackAxis}
component := suite.Component{Lang: split(*langAxis), Stack: split(*stackAxis)}
for i, name := range topics {
if i > 0 {
fmt.Fprintln(env.Out)
@@ -141,6 +142,19 @@ func listRetired(w io.Writer, s *suite.Suite) {
fmt.Fprintln(w, "\nnone of these names is ever handed out again")
}
// split reads a comma-separated list off the command line. An axis of a
// component is a list — a component may sit on two stacks at once — and one
// flag repeated is worse to type than one flag with commas in it.
func split(value string) []string {
var out []string
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {