// Package docs creates and checks the ADR and plan records described in docs/docs-format.md. package docs import ( "bytes" "embed" "fmt" "os" "path/filepath" "slices" "strings" "unicode/utf8" ) //go:embed templates/*.md var templates embed.FS var dirs = []struct{ kind, dir string }{{"adr", "docs/adr"}, {"plan", "docs/plans"}} // Problem is one violation of the format. Line is 1 for file-level problems. type Problem struct { Path string Line int Msg string } func (p Problem) String() string { return fmt.Sprintf("%s:%d: %s", p.Path, p.Line, p.Msg) } // Result is the outcome of Check: the number of records read and the sorted problems. type Result struct { Records int Problems []Problem } type record struct { path, kind, stem, date string status string lists map[string][]item fm *fm } type checker struct { root, realRoot string problems []Problem } func (c *checker) add(path string, line int, format string, args ...any) { c.problems = append(c.problems, Problem{path, line, fmt.Sprintf(format, args...)}) } // Check validates every record under root. The error is for I/O failures only. func Check(root string) (Result, error) { real, err := filepath.EvalSymlinks(root) if err != nil { return Result{}, err } c := &checker{root: root, realRoot: real} var recs []*record for _, d := range dirs { if st, err := os.Lstat(filepath.Join(root, d.dir)); os.IsNotExist(err) { continue } else if err == nil && !st.IsDir() { c.add(d.dir, 1, "must be a real directory (symlinks are not allowed)") continue } entries, err := os.ReadDir(filepath.Join(root, d.dir)) if err != nil { return Result{}, err } for _, e := range entries { name := e.Name() if strings.HasPrefix(name, ".") || name == "README.md" { continue } rel := d.dir + "/" + name if !e.Type().IsRegular() { c.add(rel, 1, "must be a regular file (subdirectories, symlinks and other entries are not allowed)") continue } stem, at, msg := parseName(name) if msg != "" { c.add(rel, 1, "%s", msg) continue } r := &record{path: rel, kind: d.kind, stem: stem, date: at.Format("2006-01-02"), lists: map[string][]item{}} if err := c.record(r); err != nil { return Result{}, err } recs = append(recs, r) } } c.graph(recs) slices.SortFunc(c.problems, func(a, b Problem) int { return strings.Compare(fmt.Sprintf("%s\x00%09d\x00%s", a.Path, a.Line, a.Msg), fmt.Sprintf("%s\x00%09d\x00%s", b.Path, b.Line, b.Msg)) }) return Result{len(recs), c.problems}, nil } func (c *checker) record(r *record) error { data, err := os.ReadFile(filepath.Join(c.root, filepath.FromSlash(r.path))) if err != nil { return err } if bytes.HasPrefix(data, []byte("\xef\xbb\xbf")) { c.add(r.path, 1, "file must not start with a byte order mark") return nil } if !utf8.Valid(data) { c.add(r.path, 1, "file must be valid UTF-8") return nil } src := strings.ReplaceAll(string(data), "\r\n", "\n") front, body, bodyLine, msg := splitFrontmatter(src) if msg != "" { c.add(r.path, 1, "%s", msg) return nil } m, ok := c.parseFrontmatter(r, front) if !ok { return nil } r.fm = m var title string var titleOK bool if r.kind == "adr" { title, titleOK = c.adr(r, m) } else { c.plan(r, m) } c.body(r, body, bodyLine, title, titleOK) return nil }