Files
bookhoard/internal/docs/frontmatter.go
T
John O'Keefe cc893f758e feat(docs): drift gate — docs frontmatter validated against real routes
Every API doc that declares frontmatter (method/path/auth/area) is now
checked by a unit test against three sources of truth:

1. Route registration: internal/router/*.go is parsed the way echo resolves
   it (group prefixes concatenate; scanner.go's parameterized groups seeded).
   Doc paths that no longer exist fail. (This is the check that would have
   caught the /api/api/admin/hash-conflicts split.)
2. Auth: frontmatter auth label must match the middleware chain
   (none|jwt|jwt+admin|device-token|optional-jwt).
3. Shapes: internal/docs/schema.go registry — example-request decoded with
   unknown fields DISALLOWED (typo guard), required-by-validate fields must
   appear (staleness guard), example-response top-level key set compared
   both directions against the marshaled handler type (or a RawMessage pin
   for hand-built map responses).

Five collections docs are the pilots with real handler shapes; notable
corrections they pin: update returns 201 (not 200), list excludes smart
collections unless include_auto=true, rule priority is 1-10 higher-wins
(docs claimed 1=highest), created_at is Go time.String() not RFC3339,
hide_finished/show_up_next toggles documented. CollectionResponse hoisted
out of GetCollections/GetBookCollections as CollectionSummary /
BookCollectionSummary so the registry can pin real types.

The gate fails on missing registry entries by design — that is the
maintenance contract. Known pre-existing (not this commit): vet copy-lock
warnings in media.go.
2026-10-24 14:41:02 -04:00

147 lines
4.1 KiB
Go

package docs
import (
"os"
"path/filepath"
"strings"
)
// DocFrontmatter is the machine-readable identity header of an API doc page.
// It is the contract the drift gate (drift_test.go) checks against the real
// router registrations and the schema registry (schema.go).
type DocFrontmatter struct {
Method string // GET|POST|PUT|PATCH|DELETE
Path string // Echo syntax, e.g. /api/collections/:id
Auth string // none|jwt|jwt+admin|device-token|optional-jwt
Area string // sidebar area, e.g. collections
}
// APIDoc is one endpoint doc file: its frontmatter plus the labeled example
// blocks extracted from the body.
type APIDoc struct {
RelPath string
Frontmatter DocFrontmatter
ExampleRequest []byte
ExampleResponse []byte
HasExampleResponse bool
Unverified []string // UNVERIFIED markers found in the body
}
// parseFrontmatter splits a leading "---" delimited block of flat key:value
// lines off content. Recognized keys: method, path, auth, area. Returns the
// frontmatter and the remaining body; hasFM is false when no well-formed
// block is present (prose pages like overviews have none and are skipped by
// the gate).
func parseFrontmatter(content string) (fm DocFrontmatter, body string, hasFM bool) {
lines := strings.Split(content, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return fm, content, false
}
end := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
end = i
break
}
}
if end == -1 {
return fm, content, false
}
for _, line := range lines[1:end] {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
switch key {
case "method":
fm.Method = strings.ToUpper(value)
case "path":
fm.Path = value
case "auth":
fm.Auth = strings.ToLower(value)
case "area":
fm.Area = strings.ToLower(value)
}
}
if fm.Method == "" || fm.Path == "" {
return fm, content, false // identity is the minimum for a gateable doc
}
return fm, strings.Join(lines[end+1:], "\n"), true
}
// extractExampleBlocks pulls fenced code blocks tagged
// "json example-request" / "json example-response" out of a doc body, and
// counts UNVERIFIED markers.
func extractExampleBlocks(body string, doc *APIDoc) {
lines := strings.Split(body, "\n")
for i := 0; i < len(lines); i++ {
fence := strings.TrimSpace(lines[i])
switch {
case fence == "```json example-request":
doc.ExampleRequest = extractUntilClose(lines, i+1)
i = skipBlock(lines, i+1)
case fence == "```json example-response":
doc.ExampleResponse = extractUntilClose(lines, i+1)
doc.HasExampleResponse = true
i = skipBlock(lines, i+1)
}
if strings.Contains(lines[i], "UNVERIFIED:") {
doc.Unverified = append(doc.Unverified, strings.TrimSpace(lines[i]))
}
}
}
func extractUntilClose(lines []string, start int) []byte {
var b strings.Builder
for i := start; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "```" {
break
}
b.WriteString(lines[i])
b.WriteString("\n")
}
return []byte(b.String())
}
// skipBlock returns the index of the closing fence line so the outer loop
// resumes after it.
func skipBlock(lines []string, start int) int {
for i := start; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "```" {
return i
}
}
return len(lines) - 1
}
// LoadAPIDocs walks dir recursively and returns every markdown file carrying
// well-formed endpoint frontmatter. Prose pages (overviews, contracts) are
// skipped: the gate only checks docs that claim to describe an endpoint.
func LoadAPIDocs(dir string) ([]APIDoc, error) {
var docs []APIDoc
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
return err
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
fm, body, hasFM := parseFrontmatter(string(raw))
if !hasFM {
return nil
}
doc := APIDoc{
RelPath: path,
Frontmatter: fm,
}
extractExampleBlocks(body, &doc)
docs = append(docs, doc)
return nil
})
return docs, err
}