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,285 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
var (
|
||||
adrSections = []string{"Context and Problem Statement", "Considered Options", "Decision Outcome"}
|
||||
planSections = []string{"Scope", "Steps", "Validation"}
|
||||
)
|
||||
|
||||
type heading struct {
|
||||
level int
|
||||
text string
|
||||
line int
|
||||
at int // index among the document's top-level nodes
|
||||
}
|
||||
|
||||
type doc struct {
|
||||
c *checker
|
||||
r *record
|
||||
src []byte
|
||||
startLine int
|
||||
kids []ast.Node
|
||||
heads []heading
|
||||
}
|
||||
|
||||
func (d *doc) lineAt(off int) int {
|
||||
return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n"))
|
||||
}
|
||||
|
||||
// lineOf is the line of the first text under n, or of the nearest block that has lines.
|
||||
func (d *doc) lineOf(n ast.Node) int {
|
||||
var off = -1
|
||||
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
if t, ok := c.(*ast.Text); ok && enter {
|
||||
off = t.Segment.Start
|
||||
return ast.WalkStop, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
for p := n; off < 0 && p != nil; p = p.Parent() {
|
||||
if p.Type() == ast.TypeBlock && p.Lines().Len() > 0 {
|
||||
off = p.Lines().At(0).Start
|
||||
}
|
||||
}
|
||||
if off < 0 {
|
||||
return d.startLine
|
||||
}
|
||||
return d.lineAt(off)
|
||||
}
|
||||
|
||||
func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) {
|
||||
b := []byte(src)
|
||||
ctx := parser.NewContext()
|
||||
root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx))
|
||||
d := &doc{c: c, r: r, src: b, startLine: startLine}
|
||||
for n := root.FirstChild(); n != nil; n = n.NextSibling() {
|
||||
if h, ok := n.(*ast.Heading); ok {
|
||||
line := d.startLine
|
||||
if h.Lines().Len() > 0 {
|
||||
line = d.lineAt(h.Lines().At(0).Start)
|
||||
}
|
||||
d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)})
|
||||
}
|
||||
d.kids = append(d.kids, n)
|
||||
}
|
||||
d.placeholders(root)
|
||||
d.links(root, ctx)
|
||||
d.title(title, titleOK)
|
||||
if r.kind == "adr" {
|
||||
for _, name := range adrSections {
|
||||
d.section(name)
|
||||
}
|
||||
d.decisionOutcome()
|
||||
} else {
|
||||
for _, name := range planSections {
|
||||
d.section(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// title checks the single leading H1.
|
||||
func (d *doc) title(title string, titleOK bool) {
|
||||
h1s := 0
|
||||
for _, h := range d.heads {
|
||||
if h.level == 1 {
|
||||
if h1s++; h1s > 1 {
|
||||
d.c.add(d.r.path, h.line, "more than one H1")
|
||||
}
|
||||
}
|
||||
}
|
||||
switch first := d.heads; {
|
||||
case len(first) == 0 || first[0].level != 1:
|
||||
d.c.add(d.r.path, d.startLine, "the first heading must be an H1")
|
||||
case d.r.kind == "plan" && first[0].text == "":
|
||||
d.c.add(d.r.path, first[0].line, "the H1 (plan title) must not be empty")
|
||||
case d.r.kind == "adr" && titleOK && first[0].text != title:
|
||||
d.c.add(d.r.path, first[0].line, "H1 %q must equal title %q", first[0].text, title)
|
||||
}
|
||||
}
|
||||
|
||||
// extent returns the top-level nodes after heading h up to the next heading of level <= upTo.
|
||||
func (d *doc) extent(h heading, upTo int) []ast.Node {
|
||||
end := len(d.kids)
|
||||
for _, o := range d.heads {
|
||||
if o.at > h.at && o.level <= upTo {
|
||||
end = o.at
|
||||
break
|
||||
}
|
||||
}
|
||||
return d.kids[h.at+1 : end]
|
||||
}
|
||||
|
||||
// section finds the one H2 called name and checks that it has prose.
|
||||
func (d *doc) section(name string) (heading, bool) {
|
||||
var found []heading
|
||||
for _, h := range d.heads {
|
||||
if h.level == 2 && h.text == name {
|
||||
found = append(found, h)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(found) == 0:
|
||||
d.c.add(d.r.path, 1, "missing required section %q", name)
|
||||
case len(found) > 1:
|
||||
d.c.add(d.r.path, found[1].line, "section %q appears more than once", name)
|
||||
case !d.hasProse(d.extent(found[0], 2)):
|
||||
d.c.add(d.r.path, found[0].line, "section %q has no prose", name)
|
||||
return found[0], true
|
||||
default:
|
||||
return found[0], true
|
||||
}
|
||||
return heading{}, false
|
||||
}
|
||||
|
||||
func (d *doc) decisionOutcome() {
|
||||
for _, h := range d.heads {
|
||||
if h.level == 2 && h.text == "Consequences" {
|
||||
d.c.add(d.r.path, h.line, "Consequences must be a ### subsection of Decision Outcome, not a ## section")
|
||||
}
|
||||
}
|
||||
var outcome []heading
|
||||
for _, h := range d.heads {
|
||||
if h.level == 2 && h.text == "Decision Outcome" {
|
||||
outcome = append(outcome, h)
|
||||
}
|
||||
}
|
||||
if len(outcome) != 1 {
|
||||
return
|
||||
}
|
||||
h := outcome[0]
|
||||
nodes := d.extent(h, 2)
|
||||
// Prose before the first subsection is the decision itself.
|
||||
pre := nodes
|
||||
for i, n := range nodes {
|
||||
if _, ok := n.(*ast.Heading); ok {
|
||||
pre = nodes[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !d.hasProse(pre) {
|
||||
d.c.add(d.r.path, h.line, "Decision Outcome needs prose before its first subsection")
|
||||
}
|
||||
var cons []heading
|
||||
for _, s := range d.heads {
|
||||
if s.level == 3 && s.text == "Consequences" && s.at > h.at && s.at < h.at+len(nodes)+1 {
|
||||
cons = append(cons, s)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(cons) == 0:
|
||||
d.c.add(d.r.path, h.line, "Decision Outcome must contain a ### Consequences subsection")
|
||||
case len(cons) > 1:
|
||||
d.c.add(d.r.path, cons[1].line, "### Consequences appears more than once")
|
||||
case !d.hasProse(d.extent(cons[0], 3)):
|
||||
d.c.add(d.r.path, cons[0].line, "### Consequences has no prose")
|
||||
}
|
||||
}
|
||||
|
||||
// hasProse reports whether nodes hold a paragraph with text outside code, images and raw HTML.
|
||||
func (d *doc) hasProse(nodes []ast.Node) bool {
|
||||
for _, n := range nodes {
|
||||
found := false
|
||||
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
switch c.(type) {
|
||||
case *ast.Paragraph, *ast.TextBlock:
|
||||
if enter && strings.TrimSpace(d.inlineText(c)) != "" {
|
||||
found = true
|
||||
return ast.WalkStop, nil
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
if found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *doc) inlineText(n ast.Node) string {
|
||||
var s strings.Builder
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch t := c.(type) {
|
||||
case *ast.Text:
|
||||
s.Write(t.Segment.Value(d.src))
|
||||
case *ast.String:
|
||||
s.Write(t.Value)
|
||||
case *ast.CodeSpan, *ast.RawHTML, *ast.Image:
|
||||
default:
|
||||
s.WriteString(d.inlineText(c))
|
||||
}
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// placeholders reports every marker outside code.
|
||||
func (d *doc) placeholders(root ast.Node) {
|
||||
var code [][2]int
|
||||
_ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
switch n.(type) {
|
||||
case *ast.FencedCodeBlock, *ast.CodeBlock:
|
||||
for i := range n.Lines().Len() {
|
||||
seg := n.Lines().At(i)
|
||||
code = append(code, [2]int{seg.Start, seg.Stop})
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
code = append(code, [2]int{t.Segment.Start, t.Segment.Stop})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
for off := 0; ; {
|
||||
i := strings.Index(string(d.src[off:]), marker)
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
at := off + i
|
||||
off = at + len(marker)
|
||||
inCode := false
|
||||
for _, r := range code {
|
||||
inCode = inCode || at >= r[0] && at < r[1]
|
||||
}
|
||||
if !inCode {
|
||||
d.c.add(d.r.path, d.lineAt(at), "unfinished placeholder %s", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// links checks every link, image and autolink destination and every reference definition.
|
||||
func (d *doc) links(root ast.Node, ctx parser.Context) {
|
||||
_ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
if !enter {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch t := n.(type) {
|
||||
case *ast.Link:
|
||||
d.c.checkLink(d.r, d.lineOf(n), string(t.Destination))
|
||||
case *ast.Image:
|
||||
d.c.checkLink(d.r, d.lineOf(n), string(t.Destination))
|
||||
case *ast.AutoLink:
|
||||
d.c.checkLink(d.r, d.lineOf(n), string(t.URL(d.src)))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
for _, ref := range ctx.References() {
|
||||
line := d.startLine
|
||||
if m := regexp.MustCompile(`(?im)^ {0,3}\[` + regexp.QuoteMeta(string(ref.Label())) + `\]:`).FindIndex(d.src); m != nil {
|
||||
line = d.lineAt(m[0])
|
||||
}
|
||||
d.c.checkLink(d.r, line, string(ref.Destination()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user