Files
go/internal/docs/body.go
T
endandClaude Sonnet 5.5 694c43051e
ci / check (push) Successful in 59s
ci / check (pull_request) Successful in 58s
fix: harden docs checker heading lines, definitions and docs symlinks
- N1: never call Lines() on inline nodes; locate empty ATX headings after the
  end of all preceding blocks (no panic, correct line after `***`)
- N2: take reference definition lines from ast.LinkReferenceDefinition and
  drop refCtx/defRe
- N3: reject docs/adr and docs/plans that resolve outside the repository

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:03:26 -07:00

323 lines
8.9 KiB
Go

package docs
import (
"bytes"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
var (
adrSections = []string{"Context and Problem Statement", "Considered Options", "Decision Outcome"}
planSections = []string{"Scope", "Steps", "Validation"}
)
var atxRe = regexp.MustCompile(`(?m)^ {0,3}#{1,6}(?:[ \t]|$)`)
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"))
}
// blockEnd is the end offset of the last line under n. Only block nodes have lines.
func blockEnd(n ast.Node) (end int) {
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
if enter && c.Type() == ast.TypeBlock {
for i := range c.Lines().Len() {
end = max(end, c.Lines().At(i).Stop)
}
}
return ast.WalkContinue, nil
})
return end
}
// emptyHeadingAt is the offset of an empty ATX heading, which has no lines: the first `#`
// line at or after from, the end of everything before it.
func (d *doc) emptyHeadingAt(from int) int {
if m := atxRe.FindIndex(d.src[from:]); m != nil {
return from + m[0]
}
return from
}
// lineOf is the line of the first text under n. Without text (an autolink, an image with
// no alt text) it is the line where needle appears in the enclosing block, else the block's first line.
func (d *doc) lineOf(n ast.Node, needle string) 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 i := bytes.Index(d.src[off:p.Lines().At(p.Lines().Len()-1).Stop], []byte(needle)); needle != "" && i >= 0 {
off += i
}
}
}
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)
root := goldmark.DefaultParser().Parse(text.NewReader(b))
d := &doc{c: c, r: r, src: b, startLine: startLine}
from := 0 // end of the blocks seen so far
for n := root.FirstChild(); n != nil; n = n.NextSibling() {
if h, ok := n.(*ast.Heading); ok {
at := from
if h.Lines().Len() > 0 {
at = h.Lines().At(0).Start
} else {
at = d.emptyHeadingAt(from)
from = at + bytes.IndexByte(d.src[at:], '\n') + 1 // the next line; ^ would match mid-line
}
d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), d.lineAt(at), len(d.kids)})
}
from = max(from, blockEnd(n))
d.kids = append(d.kids, n)
}
d.placeholders(root)
d.links(root)
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 h, ok := n.(*ast.Heading); ok && h.Level == 3 {
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()
}
// codeRanges returns the source ranges of code blocks and code spans.
func codeRanges(root ast.Node) (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
})
return code
}
// placeholders reports every marker outside code.
func (d *doc) placeholders(root ast.Node) {
code := codeRanges(root)
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)
}
}
}
// dest decodes a link destination as CommonMark does before it is used.
func dest(b []byte) string {
return string(util.ResolveEntityNames(util.ResolveNumericReferences(util.UnescapePunctuations(b))))
}
// links checks every link, image and autolink destination and every reference definition.
func (d *doc) links(root ast.Node) {
_ = 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)), dest(t.Destination))
case *ast.Image:
d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination))
case *ast.AutoLink:
d.c.checkLink(d.r, d.lineOf(n, "<"+string(t.Label(d.src))), string(t.URL(d.src)))
case *ast.LinkReferenceDefinition:
d.c.checkLink(d.r, d.lineAt(t.Lines().At(0).Start), dest(t.Destination))
}
return ast.WalkContinue, nil
})
}