feat: add docs checker for ADRs and implementation plans (#1)
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:
1 parent
37b0516fd2
commit
cc482d9a9b
27 files changed
+2464
No files matched your search
@@ -0,0 +1,66 @@
|
||||
// Command docs creates and checks ADR and plan records. See docs/docs-format.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.auvem.com/corestack/internal/docs"
|
||||
)
|
||||
|
||||
const usage = `usage:
|
||||
docs new adr|plan "Title"
|
||||
docs check [--root DIR]
|
||||
`
|
||||
|
||||
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now())) }
|
||||
|
||||
// run returns the exit status: 0 success, 1 problems or failure, 2 usage error.
|
||||
func run(args []string, stdout, stderr io.Writer, now time.Time) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "new":
|
||||
if len(args) != 3 || (args[1] != "adr" && args[1] != "plan") {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
path, err := docs.New(".", args[1], args[2], now)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "docs: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stdout, path)
|
||||
return 0
|
||||
case "check":
|
||||
fs := flag.NewFlagSet("docs check", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
root := fs.String("root", ".", "repository root")
|
||||
if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 {
|
||||
if fs.NArg() != 0 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
}
|
||||
return 2
|
||||
}
|
||||
res, err := docs.Check(*root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "docs: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, p := range res.Problems {
|
||||
fmt.Fprintln(stdout, p)
|
||||
}
|
||||
if len(res.Problems) > 0 {
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "ok: %d records checked\n", res.Records)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC)
|
||||
|
||||
func do(t *testing.T, args ...string) (code int, stdout, stderr string) {
|
||||
t.Helper()
|
||||
var out, errw bytes.Buffer
|
||||
code = run(args, &out, &errw, now)
|
||||
return code, out.String(), errw.String()
|
||||
}
|
||||
|
||||
func TestCLI(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Chdir(root)
|
||||
|
||||
if code, out, _ := do(t, "check"); code != 0 || out != "ok: 0 records checked\n" {
|
||||
t.Fatalf("empty check: %d %q", code, out)
|
||||
}
|
||||
code, out, _ := do(t, "new", "adr", "Use X")
|
||||
if code != 0 || out != "docs/adr/20261023T204512345Z-use-x.md\n" {
|
||||
t.Fatalf("new: %d %q", code, out)
|
||||
}
|
||||
code, out, _ = do(t, "check")
|
||||
var want strings.Builder
|
||||
for _, line := range []string{"6", "8", "20", "24", "28", "32"} { // numeric, not lexical, order
|
||||
want.WriteString("docs/adr/20261023T204512345Z-use-x.md:" + line + ": unfinished placeholder {{docs:todo}}\n")
|
||||
}
|
||||
if code != 1 || out != want.String() {
|
||||
t.Fatalf("check with markers: %d %q", code, out)
|
||||
}
|
||||
// --root checks another directory; the working directory is not used.
|
||||
t.Chdir(t.TempDir())
|
||||
if code, _, _ := do(t, "check", "--root", root); code != 1 {
|
||||
t.Errorf("--root: %d", code)
|
||||
}
|
||||
if code, out, _ := do(t, "check"); code != 0 || !strings.HasPrefix(out, "ok: ") {
|
||||
t.Errorf("cwd root: %d %q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExitCodes(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must(os.MkdirAll("docs/adr", 0o755))
|
||||
must(os.WriteFile(filepath.Join("docs", "adr", "bad.md"), nil, 0o644))
|
||||
for _, tt := range []struct {
|
||||
args []string
|
||||
code int
|
||||
}{
|
||||
{nil, 2},
|
||||
{[]string{"bogus"}, 2},
|
||||
{[]string{"new"}, 2},
|
||||
{[]string{"new", "adr"}, 2},
|
||||
{[]string{"new", "note", "T"}, 2},
|
||||
{[]string{"new", "adr", "T", "extra"}, 2},
|
||||
{[]string{"new", "adr", "!!!"}, 1},
|
||||
{[]string{"check", "--bogus"}, 2},
|
||||
{[]string{"check", "extra"}, 2},
|
||||
{[]string{"check", "--root", "missing-dir"}, 1},
|
||||
{[]string{"check"}, 1}, // bad.md is not a valid name
|
||||
} {
|
||||
if code, _, stderr := do(t, tt.args...); code != tt.code {
|
||||
t.Errorf("docs %v = %d, want %d (stderr %q)", tt.args, code, tt.code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user