feat: add docs checker for ADRs and implementation plans (#1)
Add cmd/docs, the first corestack component: `docs new adr|plan "Title"` writes records from embedded templates, and `docs check` validates them against the public format spec in docs/docs-format.md (timestamp IDs, frontmatter keys and enums, separate decision and implementation status, supersession symmetry, MADR 4 layout with required Consequences, plan sections, and repo-root/durable references), reporting path:line errors. Also adds the module skeleton (go.auvem.com/corestack, BSD-3-Clause, README, AGENTS.md, CLAUDE.md), Gitea CI running tests and docs check, a tag-triggered goreleaser release for darwin/linux binaries, and the accepted ADR adopting the format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
1 parent
37b0516fd2
commit
cc482d9a9b
27 files changed
+2464
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// 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
|
||||
} else if r, err := filepath.EvalSymlinks(filepath.Join(root, d.dir)); err == nil && !within(real, r) {
|
||||
c.add(d.dir, 1, "must be inside the repository (a symlinked parent directory is 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
|
||||
}
|
||||
Reference in new issue
Block a user