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,176 @@
|
||||
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 {
|
||||
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 v == 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, ", ")
|
||||
}
|
||||
Reference in new issue
Block a user