Check every link reference definition, decode destinations per CommonMark, report correct lines for empty headings, autolinks and nested definitions, reject symlinked record directories and wrong-case path components, and refuse titles that cannot pass. Only top-level headings count (spec section 6). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
189 lines
5.0 KiB
Go
189 lines
5.0 KiB
Go
package docs
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// kinds is the set of reference forms a frontmatter key accepts.
|
|
type kinds uint8
|
|
|
|
const (
|
|
kAdr kinds = 1 << iota
|
|
kPlan
|
|
kURL
|
|
kFile
|
|
)
|
|
|
|
type refSpec struct {
|
|
key string
|
|
kinds kinds
|
|
}
|
|
|
|
var (
|
|
schemeRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9+.-]*):`)
|
|
durableRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+$`)
|
|
idPattern = `\d{8}T\d{9}Z-[a-z0-9]+(?:-[a-z0-9]+)*`
|
|
adrPathRe = regexp.MustCompile(`^/docs/adr/` + idPattern + `\.md$`)
|
|
planPathRe = regexp.MustCompile(`^/docs/plans/` + idPattern + `\.md$`)
|
|
)
|
|
|
|
type ref struct {
|
|
path string // decoded repo-root path; empty for URLs and anchors
|
|
remote bool
|
|
}
|
|
|
|
// parseRef checks the syntax of a reference. body allows #anchors, mailto: and #fragments.
|
|
func parseRef(v string, body bool) (ref, string) {
|
|
if v == "" {
|
|
return ref{}, "empty reference"
|
|
}
|
|
if strings.Contains(v, `\`) {
|
|
return ref{}, "backslashes are not allowed"
|
|
}
|
|
if body && v[0] == '#' {
|
|
return ref{}, ""
|
|
}
|
|
if m := schemeRe.FindStringSubmatch(v); m != nil {
|
|
switch strings.ToLower(m[1]) {
|
|
case "http", "https":
|
|
u, err := url.Parse(v)
|
|
switch {
|
|
case err != nil || u.Hostname() == "":
|
|
return ref{}, "URL must have a host"
|
|
case u.User != nil:
|
|
return ref{}, "URL must not contain credentials"
|
|
}
|
|
return ref{remote: true}, ""
|
|
case "mailto":
|
|
if body {
|
|
return ref{}, ""
|
|
}
|
|
}
|
|
return ref{}, fmt.Sprintf("scheme %q is not allowed", m[1])
|
|
}
|
|
if v[0] != '/' {
|
|
return ref{}, "relative paths are not allowed; use a repo-root path starting with /"
|
|
}
|
|
if strings.HasPrefix(v, "//") {
|
|
return ref{}, "protocol-relative references are not allowed"
|
|
}
|
|
p, frag, hasFrag := strings.Cut(v, "#")
|
|
if hasFrag && !body {
|
|
return ref{}, "fragments are not allowed here (" + frag + ")"
|
|
}
|
|
if strings.Contains(p, "?") {
|
|
return ref{}, "queries are not allowed"
|
|
}
|
|
dec, err := url.PathUnescape(p)
|
|
if err != nil {
|
|
return ref{}, "invalid percent-encoding"
|
|
}
|
|
if slices.Contains(strings.Split(dec, "/"), "..") || strings.Contains(dec, `\`) {
|
|
return ref{}, `".." segments and backslashes are not allowed`
|
|
}
|
|
return ref{path: dec}, ""
|
|
}
|
|
|
|
// exists reports why a repo-root path is not a regular file inside the repository.
|
|
func (c *checker) exists(p string) string {
|
|
// Match each component's case exactly, so case-insensitive filesystems agree with Linux.
|
|
dir := c.root
|
|
for _, part := range strings.Split(p, "/") {
|
|
if part == "" || part == "." {
|
|
continue
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil || !slices.ContainsFunc(entries, func(e os.DirEntry) bool { return e.Name() == part }) {
|
|
return "target " + p + " does not exist"
|
|
}
|
|
dir = filepath.Join(dir, part)
|
|
}
|
|
real, err := filepath.EvalSymlinks(filepath.Join(c.root, filepath.FromSlash(p)))
|
|
if err != nil {
|
|
return "target " + p + " does not exist"
|
|
}
|
|
if rel, err := filepath.Rel(c.realRoot, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "target " + p + " resolves outside the repository"
|
|
}
|
|
if st, err := os.Stat(real); err != nil || !st.Mode().IsRegular() {
|
|
return "target " + p + " is not a regular file"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// checkLink validates a body link destination.
|
|
func (c *checker) checkLink(r *record, line int, dest string) {
|
|
ref, msg := parseRef(dest, true)
|
|
if msg == "" && ref.path != "" {
|
|
msg = c.exists(ref.path)
|
|
}
|
|
if msg != "" {
|
|
c.add(r.path, line, "link %q: %s", dest, msg)
|
|
}
|
|
}
|
|
|
|
// refs validates the reference lists of a record against their specs.
|
|
func (m *fm) refs(specs ...refSpec) {
|
|
self := "/" + m.r.path
|
|
for _, spec := range specs {
|
|
items, ok := m.list(spec.key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, it := range items {
|
|
if it.v == marker {
|
|
continue
|
|
}
|
|
if msg := m.checkRef(spec, it.v, self); msg != "" {
|
|
m.c.add(m.r.path, it.line, "%s: %s", spec.key, msg)
|
|
} else if seen[it.v] {
|
|
m.c.add(m.r.path, it.line, "%s: duplicate reference %q", spec.key, it.v)
|
|
}
|
|
seen[it.v] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *fm) checkRef(spec refSpec, v, self string) string {
|
|
if durableRe.MatchString(v) || slices.Contains([]string{"this pr", "this pull request", "this commit"}, strings.ToLower(v)) {
|
|
return fmt.Sprintf("%q is not durable; use a full URL", v)
|
|
}
|
|
r, msg := parseRef(v, false)
|
|
if msg != "" {
|
|
return msg
|
|
}
|
|
k := spec.kinds
|
|
switch {
|
|
case r.remote:
|
|
if k&kURL == 0 {
|
|
return "URLs are not accepted here; accepts " + k.String()
|
|
}
|
|
return ""
|
|
case k&kFile == 0 && !(k&kAdr != 0 && adrPathRe.MatchString(v)) && !(k&kPlan != 0 && planPathRe.MatchString(v)):
|
|
return fmt.Sprintf("%q is not accepted here; accepts %s", v, k)
|
|
case r.path == self:
|
|
return "a record must not reference itself"
|
|
}
|
|
return m.c.exists(r.path)
|
|
}
|
|
|
|
func (k kinds) String() string {
|
|
var s []string
|
|
for _, e := range []struct {
|
|
k kinds
|
|
s string
|
|
}{{kAdr, "/docs/adr/<id>.md"}, {kPlan, "/docs/plans/<id>.md"}, {kURL, "http(s) URLs"}, {kFile, "repo-root file paths"}} {
|
|
if k&e.k != 0 {
|
|
s = append(s, e.s)
|
|
}
|
|
}
|
|
return strings.Join(s, ", ")
|
|
}
|