feat: add docs checker and record templates
docs new creates ADRs and plans from embedded templates; docs check reports path:line: message problems per docs/docs-format.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0428256bd
commit
750b7449b0
15 files changed
+1856
No files matched your search
@@ -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...)
|
||||
}
|
||||
Reference in new issue
Block a user