fix: harden docs checker heading lines, definitions and docs symlinks
ci / check (push) Successful in 59s
ci / check (pull_request) Successful in 58s

- 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>
This commit is contained in:
endandClaude Sonnet 5.5 committed 2026-10-23 15:03:26 -07:00
1 parent 9eec1d1016
commit 694c43051e
6 files changed
+59 -62

No files matched your search

+32 -60
View File
@@ -3,12 +3,10 @@ package docs
import (
"bytes"
"regexp"
"slices"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
@@ -40,30 +38,26 @@ func (d *doc) lineAt(off int) int {
return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n"))
}
// headingLine is the line of h. An empty ATX heading has no lines, so it is the first
// `#` line after the end of the previous top-level block.
func (d *doc) headingLine(h *ast.Heading, prev ast.Node) int {
if h.Lines().Len() > 0 {
return d.lineAt(h.Lines().At(0).Start)
}
from := 0
if prev != nil {
_ = ast.Walk(prev, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
for i := range c.Lines().Len() {
from = max(from, c.Lines().At(i).Stop)
}
if t, ok := c.(*ast.Text); ok {
from = max(from, t.Segment.Stop)
}
// 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 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 d.lineAt(from + m[0])
return from + m[0]
}
return d.startLine
return from
}
// lineOf is the line of the first text under n. Without text (an autolink, an image with
@@ -93,18 +87,25 @@ func (d *doc) lineOf(n ast.Node, needle string) int {
func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) {
b := []byte(src)
ctx := &refCtx{Context: parser.NewContext()}
root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx))
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 {
line := d.headingLine(h, n.PreviousSibling())
d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)})
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, ctx)
d.links(root)
d.title(title, titleOK)
if r.kind == "adr" {
for _, name := range adrSections {
@@ -295,27 +296,13 @@ func (d *doc) placeholders(root ast.Node) {
}
}
// refCtx records every link reference definition; the parser keeps only the first of a label.
type refCtx struct {
parser.Context
all []parser.Reference
}
func (c *refCtx) AddReference(r parser.Reference) {
c.all = append(c.all, r)
c.Context.AddReference(r)
}
// defRe finds a definition's label at the start of a line, allowing blockquote and list prefixes.
var defRe = regexp.MustCompile(`(?m)^[ \t>]*(?:(?:[*+-]|\d+[.)])[ \t]+)*\[([^\]\n]+)\]:`)
// 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, ctx *refCtx) {
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
@@ -327,24 +314,9 @@ func (d *doc) links(root ast.Node, ctx *refCtx) {
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
})
// shortcut: a definition's line is its label's nth match outside code; a multi-line
// definition inside a container may be located by label only. Upgrade by tracking offsets.
code := codeRanges(root)
lines := map[string][]int{}
for _, m := range defRe.FindAllSubmatchIndex(d.src, -1) {
if !slices.ContainsFunc(code, func(r [2]int) bool { return m[0] >= r[0] && m[0] < r[1] }) {
k := util.ToLinkReference(d.src[m[2]:m[3]])
lines[k] = append(lines[k], d.lineAt(m[0]))
}
}
for _, ref := range ctx.all {
line, k := d.startLine, util.ToLinkReference(ref.Label())
if len(lines[k]) > 0 {
line, lines[k] = lines[k][0], lines[k][1:]
}
d.c.checkLink(d.r, line, dest(ref.Destination()))
}
}