feat: add docs checker for ADRs and implementation plans (#1)
release / release (push) Successful in 17s
ci / check (push) Successful in 18s

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:
endandClaude Opus 5.5 committed 2026-10-23 15:56:03 -07:00
1 parent 37b0516fd2
commit cc482d9a9b
27 files changed
+2464

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
package docs
import (
"errors"
"io"
"regexp"
"slices"
"strconv"
"strings"
"go.yaml.in/yaml/v3"
)
const marker = "{{docs:todo}}"
var yamlLineRe = regexp.MustCompile(`line (\d+)`)
type field struct{ key, val *yaml.Node }
type item struct {
v string
line int
}
// fm reads the typed fields of one record's frontmatter mapping.
type fm struct {
c *checker
r *record
f map[string]field
}
// splitFrontmatter returns the frontmatter text, the body and the file line the body starts on.
func splitFrontmatter(src string) (front, body string, bodyLine int, msg string) {
lines := strings.Split(src, "\n")
if lines[0] != "---" {
return "", "", 0, "file must start with a --- frontmatter line"
}
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
return strings.Join(lines[1:i], "\n"), strings.Join(lines[i+1:], "\n"), i + 2, ""
}
}
return "", "", 0, "frontmatter is not closed by a --- line"
}
// parseFrontmatter decodes the mapping and rejects anything but plain keys and values.
func (c *checker) parseFrontmatter(r *record, front string) (*fm, bool) {
report := func(line int, format string, args ...any) { c.add(r.path, line, format, args...) }
dec := yaml.NewDecoder(strings.NewReader(front))
var doc, extra yaml.Node
if err := dec.Decode(&doc); err != nil || len(doc.Content) == 0 {
if err == nil || errors.Is(err, io.EOF) {
report(2, "frontmatter is empty")
} else {
line := 2
if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil {
n, _ := strconv.Atoi(m[1])
line += n - 1
}
report(line, "invalid YAML: %v", strings.TrimPrefix(err.Error(), "yaml: "))
}
return nil, false
}
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
report(2, "frontmatter must be a single YAML document")
return nil, false
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
report(root.Line+1, "frontmatter must be a mapping")
return nil, false
}
ok := true
var walk func(n *yaml.Node)
walk = func(n *yaml.Node) {
switch {
case n.Kind == yaml.AliasNode:
report(n.Line+1, "YAML aliases are not allowed")
ok = false
case n.Anchor != "":
report(n.Line+1, "YAML anchors are not allowed")
ok = false
case n.Style&yaml.TaggedStyle != 0:
report(n.Line+1, "explicit YAML tags are not allowed")
ok = false
}
for _, ch := range n.Content {
walk(ch)
}
}
walk(root)
m := &fm{c: c, r: r, f: map[string]field{}}
for i := 0; i+1 < len(root.Content); i += 2 {
k, v := root.Content[i], root.Content[i+1]
if k.Kind != yaml.ScalarNode {
report(k.Line+1, "keys must be plain strings")
ok = false
} else if _, dup := m.f[k.Value]; dup {
report(k.Line+1, "duplicate key %q", k.Value)
ok = false
} else {
m.f[k.Value] = field{k, v}
}
markers(v, report)
}
return m, ok
}
// markers reports an unfinished placeholder in any string value or list item.
func markers(n *yaml.Node, report func(int, string, ...any)) {
if n.Kind == yaml.ScalarNode && strings.Contains(n.Value, marker) {
report(n.Line+1, "unfinished placeholder %s", marker)
}
for _, ch := range n.Content {
markers(ch, report)
}
}
// keys reports missing and unknown keys.
func (m *fm) keys(want ...string) {
for _, k := range want {
if _, ok := m.f[k]; !ok {
m.c.add(m.r.path, 1, "missing key %q", k)
}
}
for k, f := range m.f {
if !slices.Contains(want, k) {
m.c.add(m.r.path, f.key.Line+1, "unknown key %q", k)
}
}
}
func (m *fm) line(key string) int {
if f, ok := m.f[key]; ok {
return f.key.Line + 1
}
return 1
}
func (m *fm) isNull(key string, n *yaml.Node) bool {
if n.Kind == yaml.ScalarNode && n.Tag == "!!null" {
m.c.add(m.r.path, n.Line+1, "%s must not be null", key)
return true
}
return false
}
// scalar returns the literal text of a non-null scalar value.
func (m *fm) scalar(key string) (string, int, bool) {
f, ok := m.f[key]
if !ok || m.isNull(key, f.val) {
return "", 0, false
}
if f.val.Kind != yaml.ScalarNode {
m.c.add(m.r.path, f.key.Line+1, "%s must be a string", key)
return "", 0, false
}
return f.val.Value, f.val.Line + 1, true
}
// text is a non-empty single-line string.
func (m *fm) text(key string) (string, bool) {
v, line, ok := m.scalar(key)
if ok && (strings.TrimSpace(v) == "" || strings.ContainsAny(v, "\r\n")) {
m.c.add(m.r.path, line, "%s must be a non-empty single line", key)
return v, false
}
return v, ok
}
func (m *fm) enum(key string, allowed ...string) (string, bool) {
v, line, ok := m.scalar(key)
if ok && !slices.Contains(allowed, v) {
m.c.add(m.r.path, line, "%s must be one of %s", key, strings.Join(allowed, ", "))
return v, false
}
return v, ok
}
// list returns the items of a sequence of non-null scalars.
func (m *fm) list(key string) ([]item, bool) {
f, ok := m.f[key]
if !ok || m.isNull(key, f.val) {
return nil, false
}
if f.val.Kind != yaml.SequenceNode {
m.c.add(m.r.path, f.key.Line+1, "%s must be a list", key)
return nil, false
}
var items []item
for _, n := range f.val.Content {
if m.isNull(key, n) {
continue
}
if n.Kind != yaml.ScalarNode {
m.c.add(m.r.path, n.Line+1, "%s items must be strings", key)
continue
}
items = append(items, item{n.Value, n.Line + 1})
}
m.r.lists[key] = items
return items, true
}
// tags checks the ADR tags list.
func (m *fm) tags() {
items, ok := m.list("tags")
if !ok {
return
}
if len(items) == 0 {
m.c.add(m.r.path, m.line("tags"), "tags must not be empty")
}
seen := map[string]bool{}
for _, it := range items {
switch {
case it.v == marker:
case !slugRe.MatchString(it.v):
m.c.add(m.r.path, it.line, "tag %q must be lowercase a-z0-9 words joined by single hyphens", it.v)
case seen[it.v]:
m.c.add(m.r.path, it.line, "duplicate tag %q", it.v)
}
seen[it.v] = true
}
}
func (m *fm) problemf(key, format string, args ...any) {
m.c.add(m.r.path, m.line(key), format, args...)
}