69da5df254
Adds 'review-bot validate-docmap' for CI hard-fail on docmap coverage gaps.
Usage:
git diff --name-only origin/main HEAD | \
review-bot validate-docmap --docmap .review-bot/doc-map.yml --repo-root .
Flags:
--docmap (required) path to doc-map YAML file
--repo-root (optional, default '.') root for resolving docs: paths
Two checks, both always run:
1. Coverage: every stdin file must match at least one paths: glob.
2. Stale docs: every docs: entry must exist on disk under --repo-root.
Exit codes: 0=clean, 1=failures found, 2=usage/parse error.
Tests cover: clean pass, uncovered file, stale doc, both failures,
empty stdin, blank-line stdin, and duplicate docs: deduplication.
297 lines
7.3 KiB
Go
297 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// makeDocmapYAML writes a YAML string to a temp file and returns its path.
|
|
func makeDocmapYAML(t *testing.T, content string) string {
|
|
t.Helper()
|
|
f, err := os.CreateTemp(t.TempDir(), "doc-map-*.yml")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp: %v", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(content); err != nil {
|
|
t.Fatalf("WriteString: %v", err)
|
|
}
|
|
return f.Name()
|
|
}
|
|
|
|
// makeDocFile creates a file (and any parent dirs) at the given path relative to dir.
|
|
func makeDocFile(t *testing.T, dir, rel string) {
|
|
t.Helper()
|
|
full := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(full, []byte("# doc\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
}
|
|
|
|
// captureOutput redirects outWriter/errWriter to buffers for the duration of f.
|
|
func captureOutput(f func()) (stdout, stderr string) {
|
|
var outBuf, errBuf bytes.Buffer
|
|
origOut, origErr := outWriter, errWriter
|
|
outWriter = &outBuf
|
|
errWriter = &errBuf
|
|
defer func() {
|
|
outWriter = origOut
|
|
errWriter = origErr
|
|
}()
|
|
f()
|
|
return outBuf.String(), errBuf.String()
|
|
}
|
|
|
|
func TestRunValidateDocmap_Clean(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
var code int
|
|
stdout, _ := captureOutput(func() {
|
|
code = runValidateDocmap([]string{
|
|
"--docmap", docmap,
|
|
"--repo-root", dir,
|
|
})
|
|
})
|
|
// Provide stdin via the global os.Stdin would require process tricks.
|
|
// The implementation reads os.Stdin directly; for tests we need a different approach.
|
|
// See TestRunValidateDocmap_WithStdin_* below which use a helper that replaces os.Stdin.
|
|
// This case: empty stdin (no changed files) → clean.
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for clean (empty stdin), got %d", code)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_MissingDocmapFlag(t *testing.T) {
|
|
var code int
|
|
_, stderr := captureOutput(func() {
|
|
code = runValidateDocmap([]string{})
|
|
})
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for missing --docmap, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "--docmap") {
|
|
t.Errorf("expected --docmap in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_BadYAML(t *testing.T) {
|
|
docmap := makeDocmapYAML(t, "mappings: [{{invalid")
|
|
var code int
|
|
_, stderr := captureOutput(func() {
|
|
code = runValidateDocmap([]string{"--docmap", docmap})
|
|
})
|
|
if code != 2 {
|
|
t.Errorf("expected exit 2 for bad YAML, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "failed to parse") {
|
|
t.Errorf("expected parse error in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_StaleDocs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// docs/foo.md does NOT exist on disk.
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
var code int
|
|
_, stderr := captureOutput(func() {
|
|
code = runValidateDocmap([]string{
|
|
"--docmap", docmap,
|
|
"--repo-root", dir,
|
|
})
|
|
})
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for stale docs, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "docs/foo.md") {
|
|
t.Errorf("expected stale path in stderr, got %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "stale docmap") {
|
|
t.Errorf("expected 'stale docmap' in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
// stdinValidateDocmap runs runValidateDocmap with a synthetic stdin.
|
|
func stdinValidateDocmap(t *testing.T, stdinContent string, args []string) (code int, stdout, stderr string) {
|
|
t.Helper()
|
|
// Write stdin content to a temp file and redirect os.Stdin.
|
|
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp for stdin: %v", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(stdinContent); err != nil {
|
|
t.Fatalf("WriteString for stdin: %v", err)
|
|
}
|
|
if _, err := f.Seek(0, 0); err != nil {
|
|
t.Fatalf("Seek for stdin: %v", err)
|
|
}
|
|
|
|
origStdin := os.Stdin
|
|
os.Stdin = f
|
|
defer func() { os.Stdin = origStdin }()
|
|
|
|
stdout, stderr = captureOutput(func() {
|
|
code = runValidateDocmap(args)
|
|
})
|
|
return
|
|
}
|
|
|
|
func TestRunValidateDocmap_UncoveredFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"lib/bar/uncovered.ex\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for uncovered file, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "lib/bar/uncovered.ex") {
|
|
t.Errorf("expected uncovered file in stderr, got %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "no docmap coverage") {
|
|
t.Errorf("expected 'no docmap coverage' in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_BothFailures(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// docs/foo.md intentionally missing
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"lib/bar/uncovered.ex\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for both failures, got %d", code)
|
|
}
|
|
if !strings.Contains(stderr, "no docmap coverage") {
|
|
t.Errorf("expected coverage error in stderr, got %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "stale docmap") {
|
|
t.Errorf("expected stale-docs error in stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_EmptyStdin(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
code, stdout, _ := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for empty stdin, got %d", code)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_BlankLinesSkipped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
makeDocFile(t, dir, "docs/foo.md")
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`)
|
|
|
|
// stdin with only blank lines → effectively empty, should be clean
|
|
code, stdout, _ := stdinValidateDocmap(t,
|
|
"\n \n\n",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 0 {
|
|
t.Errorf("expected exit 0 for blank-only stdin, got %d", code)
|
|
}
|
|
if !strings.Contains(stdout, "OK") {
|
|
t.Errorf("expected 'OK' in stdout for blank-only stdin, got %q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunValidateDocmap_DuplicateDocsDeduped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// docs/shared.md intentionally missing — but it appears in TWO mappings.
|
|
// Should appear only once in stale list.
|
|
|
|
docmap := makeDocmapYAML(t, `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/shared.md
|
|
- paths:
|
|
- "lib/bar/**"
|
|
docs:
|
|
- docs/shared.md
|
|
`)
|
|
|
|
code, _, stderr := stdinValidateDocmap(t,
|
|
"",
|
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
|
)
|
|
if code != 1 {
|
|
t.Errorf("expected exit 1 for stale doc, got %d", code)
|
|
}
|
|
count := strings.Count(stderr, "docs/shared.md")
|
|
if count != 1 {
|
|
t.Errorf("expected docs/shared.md to appear exactly once in stderr (deduplicated), got %d occurrences: %q", count, stderr)
|
|
}
|
|
}
|