закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user