feat: add docs checker for ADRs and implementation plans (#1)
release / release (push) Successful in 17s
ci / check (push) Successful in 18s

Add cmd/docs, the first corestack component: `docs new adr|plan "Title"`
writes records from embedded templates, and `docs check` validates them
against the public format spec in docs/docs-format.md (timestamp IDs,
frontmatter keys and enums, separate decision and implementation status,
supersession symmetry, MADR 4 layout with required Consequences, plan
sections, and repo-root/durable references), reporting path:line errors.

Also adds the module skeleton (go.auvem.com/corestack, BSD-3-Clause,
README, AGENTS.md, CLAUDE.md), Gitea CI running tests and docs check,
a tag-triggered goreleaser release for darwin/linux binaries, and the
accepted ADR adopting the format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
endandClaude Opus 5.5 committed 2026-10-23 15:56:03 -07:00
1 parent 37b0516fd2
commit cc482d9a9b
27 files changed
+2464

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
package docs
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
timestampLayout = "20060102T150405.000Z"
maxSlug = 80
)
var (
nameRe = regexp.MustCompile(`^(\d{8}T\d{9}Z)-(.+)\.md$`)
slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
)
// parseName validates a record filename and returns its stem, UTC timestamp
// and a problem message (empty when valid).
func parseName(name string) (stem string, at time.Time, msg string) {
m := nameRe.FindStringSubmatch(name)
if m == nil {
return "", at, "name must be YYYYMMDDTHHMMSSmmmZ-slug.md"
}
at, err := time.Parse(timestampLayout, m[1][:15]+"."+m[1][15:])
if err != nil {
return "", at, "name has an invalid UTC timestamp"
}
switch {
case !slugRe.MatchString(m[2]):
return "", at, "slug must be lowercase a-z0-9 words joined by single hyphens"
case len(m[2]) > maxSlug:
return "", at, fmt.Sprintf("slug is longer than %d characters", maxSlug)
}
return strings.TrimSuffix(name, ".md"), at, ""
}
// slugify lowercases title and joins its ASCII letter and digit runs with hyphens.
func slugify(title string) string {
var b strings.Builder
for _, r := range strings.ToLower(title) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
} else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
b.WriteByte('-')
}
}
s := b.String()
return strings.Trim(s[:min(len(s), maxSlug)], "-")
}
var closingRe = regexp.MustCompile(`(^|[ \t])#+$`)
// New creates a record of the given kind ("adr" or "plan") under root and
// returns its root-relative slash path. The file is created exclusively; the
// timestamp advances by 1 ms on a collision.
func New(root, kind, title string, now time.Time) (string, error) {
title = strings.TrimSpace(title)
if title == "" || strings.ContainsAny(title, "\r\n") {
return "", errors.New("title must be non-empty and a single line")
}
if closingRe.MatchString(title) {
return "", errors.New(`title must not end in a "#" run: it would be read as a closing sequence of the H1`)
}
slug := slugify(title)
if slug == "" {
return "", errors.New("title needs at least one ASCII letter or digit for the file name")
}
var dir string
for _, d := range dirs {
if d.kind == kind {
dir = d.dir
}
}
tmpl, err := templates.ReadFile("templates/" + kind + ".md")
if err != nil {
return "", fmt.Errorf("unknown record type %q", kind)
}
quoted, err := json.Marshal(title) // JSON strings are valid YAML double-quoted scalars
if err != nil {
return "", err
}
used, err := stems(root)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
return "", err
}
at := now.UTC().Truncate(time.Millisecond)
for ; ; at = at.Add(time.Millisecond) {
stem := strings.Replace(at.Format(timestampLayout), ".", "", 1) + "-" + slug
if used[stem] {
continue
}
rel := dir + "/" + stem + ".md"
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(rel)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", err
}
content := strings.NewReplacer("@TITLE_YAML@", string(quoted), "@TITLE@", title, "@DATE@", at.Format(time.DateOnly)).Replace(string(tmpl))
_, werr := f.WriteString(content)
return rel, errors.Join(werr, f.Close())
}
}
// stems returns the IDs of every record-named file in both directories.
func stems(root string) (map[string]bool, error) {
used := map[string]bool{}
for _, d := range dirs {
entries, err := os.ReadDir(filepath.Join(root, d.dir))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
for _, e := range entries {
if stem, _, msg := parseName(e.Name()); msg == "" {
used[stem] = true
}
}
}
return used, nil
}