закрыты известные остатки

- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
This commit is contained in:
av
2026-07-28 10:16:27 +03:00
parent 29d29740f5
commit 600aba5ee3
16 changed files with 413 additions and 70 deletions
+8 -1
View File
@@ -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")
forComponent := fs.String("for", "", "component to check; every one of them by default")
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 {
return Usage
}
@@ -67,7 +68,13 @@ func runCheck(env Env, args []string) ExitCode {
for _, err := range broken {
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 {
return Failed
}
+26 -8
View File
@@ -158,21 +158,39 @@ func components(env Env, m *manifest.Project, name string) ([]string, ExitCode)
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.
// distinctDirs checks that no two components write into the same place. 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.
//
// 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 {
seen := make(map[string]string)
dirs := make(map[string]string, len(m.Components))
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)
dirs[name] = filepath.ToSlash(filepath.Clean(m.Components[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
}
// 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.
func exists(name string) bool {
_, err := os.Stat(name)
+21 -1
View File
@@ -18,6 +18,7 @@ func runSuiteCheck(env Env, args []string) ExitCode {
fs.SetOutput(env.Err)
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")
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 {
return Usage
}
@@ -46,13 +47,32 @@ func runSuiteCheck(env Env, args []string) ExitCode {
}
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 {
return Failed
}
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) {
findings := rep.Findings()
printFindings(w, findings)
+23
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"git.vakhrushev.me/av/convy/internal/check"
"git.vakhrushev.me/av/convy/internal/manifest"
"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)
}
reportDangling(env, o, c)
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.
type orphan struct {
path string
+105
View File
@@ -1,6 +1,7 @@
package cli_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -164,3 +165,107 @@ func exists(t *testing.T, parts ...string) bool {
_, err := os.Stat(filepath.Join(parts...))
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"])
}
}