Compare commits

...

8 Commits

Author SHA1 Message Date
Rodin 7cdba14181 docs(#141): add validate-docmap subcommand to README
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 30s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m11s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m41s
Documents the new validate-docmap subcommand under a new '## Subcommands' section,
alongside the existing validate-url documentation.
2026-05-15 04:48:32 +00:00
Rodin 69da5df254 feat(#141): add validate-docmap subcommand
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.
2026-05-15 04:47:59 +00:00
Rodin 93268869c5 feat(#141): export FileCoveredByDocMap helper in review/docmap.go
Adds FileCoveredByDocMap(cfg *DocMapConfig, file string) bool — a thin wrapper
over the existing unexported mappingMatches that lets cmd/ check per-file docmap
coverage without duplicating glob logic.

Also adds unit tests covering matched globs, non-matching paths, empty file,
and empty config.
2026-05-15 04:46:38 +00:00
Rodin 04b24256c0 chore: dev-loop status update — PR #140 merged, coverage now 49.3%
CI / test (push) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-15 04:36:09 +00:00
rodin 1a4bab8ddc test(#139): improve cmd/review-bot coverage from 44.6% to 49.3% (#140)
CI / test (push) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-15 04:35:54 +00:00
claw d0349a6223 chore: dev-loop status update — PR #140 open, coverage 44.6% → 49.3%
CI / test (push) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-14 21:16:24 -07:00
rodin 1e3d86b604 Merge pull request 'feat(#137): add doc-map input for path-scoped doc injection' (#138) from issue-137 into main
CI / test (push) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-15 03:39:36 +00:00
Rodin cc053cfede chore: dev-loop health check — PR #138 ready for merge at 2026-05-15 03:33 UTC
CI / test (push) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-15 03:33:20 +00:00
11 changed files with 766 additions and 56 deletions
+26 -26
View File
@@ -1,6 +1,6 @@
# Dev Loop Health Check — 2026-05-15 01:33 UTC
# Dev Loop Health Check — 2026-05-15 03:33 UTC
## Status: ✅ OPTIMAL
## Status: ✅ ACTIVE WORK COMPLETED
### Test Results
- All packages: **PASS** ✅ (6/6, fresh -count=1 run)
@@ -18,33 +18,33 @@
| llm | 81.3% |
| review | 92.0% |
### Recent Activity (since last check 01:28 UTC)
- Pulled `d0b0b0b` (dev-loop health update from 01:28 cycle)
- No new commits from dev work
- No open issues or PRs
- Working tree: clean, up to date with origin/main
### PR #138 Status
### Notes on Coverage
- `cmd/review-bot` at 46.1% — main() itself at 26.5%; lowest coverage package
- Potential: integration test harness (issue #TBD)
- `vcs.go` adapter wrappers intentionally 0% — thin delegation, real logic tested in gitea/github packages
- **Branch:** issue-137
- **Feature:** feat(#137): add doc-map input for path-scoped doc injection
- **Review status:** ✅ All 3 bots approved (sonnet, gpt, security)
- **Review findings addressed:**
- Fixed package comment collision in `review/docmap.go` (sonnet #1)
- Added `truncateUTF8` duplication note (sonnet #2)
- Added debug log for directory expansion fallback (sonnet #3)
- Added `validateDocPath` — rejects absolute/`..` paths (security #3)
- Added prompt injection guardrail for DesignDocs (security #2)
- Fixed trim order comment in `budget/budget.go` (gpt #1)
- Fixed `globMatch` comment to say `filepath.Match` (gpt nit #3)
- Added `doc-map` and `doc-map-max-bytes` to README inputs table (gpt #2)
- Added tests for `validateDocPath` and path traversal rejection
- Updated CHANGELOG with security fixes
- **Labels:** ready, self-reviewed
- **Assignee:** aweiker
- **Mergeable:** ✅ yes
### Next Phase Priorities
1. **PR Submission (#132+)** — Enable review-bot to create PRs
2. **`github.Client.DismissReview`** — method referenced in orphaned files, not in client.go; file issue
3. **GitHub Enterprise Support** — Enterprise URL patterns, token scopes
4. **Increase cmd/review-bot coverage** — integration test harness for main()
5. **Performance & Observability** — Metrics, load testing, audit logging
### Next Priority
### System Health
- ✅ All tests passing
- ✅ No warnings or lint issues
- ✅ Code clean, working tree clean
- ✅ No open issues or PRs on Gitea
- ✅ Ready for next development cycle
- Await merge of PR #138
- After merge: increase cmd/review-bot coverage (46.1% → target 60%+)
- Issue #132+: PR Submission feature
- `github.Client.DismissReview` method referenced but missing — file issue
---
**Previous check:** 2026-05-15 01:28 UTC
**This check:** 2026-05-15 01:33 UTC
**Action:** NONE — healthy, no work to do
_Dev-loop cycle complete at 03:33 UTC._
+18 -25
View File
@@ -1,43 +1,36 @@
=============================================================================
REVIEW-BOT DEV LOOP STATUS — 2026-05-15 01:48 UTC (post-sync)
REVIEW-BOT DEV LOOP STATUS — 2026-05-15 04:08 UTC
=============================================================================
OVERALL STATUS: ✅ OPTIMAL
OVERALL STATUS: ✅ PR OPEN
Test Results (fresh run post-sync):
Active Work:
- PR #140: test(#139): improve cmd/review-bot coverage 44.6% → 49.3%
State: open, labeled: ready, self-reviewed
Branch: issue-139
Test Results (last full run, worktree):
- All 6 packages: PASS ✅
- Build: ✅ clean
- Vet: ✅ clean
- Fresh run: -count=1 verified
Recent Major Changes (synced from origin/main):
- Significant new GitHub client methods (~360 lines added)
- New validateurl package for URL validation
- New vcs adapter layer for VCS abstraction
- New gitea/ipcheck package for IP validation
- Expanded integration tests in cmd/review-bot
- All changes verified passing tests
Coverage (current post-sync):
- review: 92.0%
- budget: 91.8%
Coverage (post-change):
- cmd/review-bot: 49.3% (was 44.6%)
- review: 91.9%
- budget: 92.0%
- github: 86.3%
- gitea: 85.2%
- llm: 81.3%
- cmd/review-bot: 46.1%
Repository:
- Branch: main (synced with origin — 4ffa6b6)
Repository (main):
- Branch: main (up to date with origin — 1e3d86b)
- Working tree: clean
- Open issues: 0
- Open PRs: 0
- Open issues: 1 (#139, addressed by PR #140)
- Open PRs: 1 (#140, ready for review)
System Health: ✅ GREEN
✓ All tests passing (33 commits synced)
✓ All tests passing
✓ No warnings
Code clean
✓ Ready for feature work
Next Cycle: Ready to pick up feature work
PR ready for merge
=============================================================================
+34
View File
@@ -296,6 +296,40 @@ review-bot \
--conventions-file CONVENTIONS.md
```
## Subcommands
### `validate-docmap`
Verifies that a `doc-map.yml` is consistent before running a review. Two checks:
1. **Coverage**: every changed file is matched by at least one `paths:` glob.
2. **Stale docs**: every `docs:` entry exists on disk under `--repo-root`.
```bash
# Typical CI usage — pipe git diff into the command
git diff --name-only origin/main HEAD | \
review-bot validate-docmap \
--docmap .review-bot/doc-map.yml \
--repo-root .
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--docmap` | Yes | — | Path to doc-map YAML file |
| `--repo-root` | No | `.` (cwd) | Root for resolving `docs:` paths |
Exit codes: `0`=clean, `1`=failures found, `2`=usage/parse error.
### `validate-url`
Resolves a URL and verifies all IPs are publicly routable (used in CI to prevent SSRF).
```bash
review-bot validate-url https://gitea.example.com
```
Exit codes: `0`=safe, `1`=blocked/private IP, `2`=error.
## Environment Variables
All flags have environment variable equivalents:
+10 -5
View File
@@ -1,4 +1,4 @@
## Dev Loop Status: 2026-05-15 02:28 UTC
## Dev Loop Status: 2026-05-15 04:35 UTC
**Repository:** review-bot (rodin/review-bot on Gitea)
**Status:** ✅ OPTIMAL
@@ -15,23 +15,28 @@
### Recent Changes
Last commit: `dcfd360` (2026-05-15 01:48) — health check post-sync
- ✅ Merged PR #140: test coverage improvements (44.6% → 49.3%)
- ✅ PR #137 (doc-map feature) merged successfully with all reviews approved
### Coverage
| Package | Coverage |
|---------|----------|
| cmd/review-bot | 46.1% |
| cmd/review-bot | 49.3% ↑ |
| gitea | 85.2% |
| github | 86.3% |
| review | 92.0% |
### Status Summary
All recent feature work (doc-map, coverage improvements) successfully integrated. Repository is stable and ready for next development cycle.
### Next Priority
- Increase cmd/review-bot coverage (lowest at 46.1%)
- Continue increasing cmd/review-bot coverage (target: ≥60%)
- Monitor prod logs for edge cases
- VCS integration stable; GitHub + Gitea paths clear
---
_Dev-loop cycle complete at 02:28 UTC._
_Dev-loop cycle complete at 04:35 UTC._
+2
View File
@@ -64,6 +64,8 @@ func main() {
switch os.Args[1] {
case "validate-url":
os.Exit(runValidateURL(os.Args[2:]))
case "validate-docmap":
os.Exit(runValidateDocmap(os.Args[2:]))
}
}
+123
View File
@@ -1383,3 +1383,126 @@ func TestFetchPatterns_MultipleRepos(t *testing.T) {
t.Errorf("expected Elixir pipes content, got: %q", got)
}
}
// TestMainSubprocess_MissingLLMBaseURL confirms that --llm-base-url is required
// when provider=openai (the default).
func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--vcs-url", "https://gitea.example.com",
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-token", "tok",
"--llm-model", "gpt-4",
// --llm-base-url and --llm-api-key intentionally omitted
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_MissingLLMBaseURL")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit when llm-base-url is missing")
}
if !strings.Contains(string(out), "llm-base-url") {
t.Errorf("expected error mentioning llm-base-url, got: %s", out)
}
}
// TestMainSubprocess_MissingAICoreCredentials confirms that aicore-specific credentials
// are required when provider=aicore.
func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--vcs-url", "https://gitea.example.com",
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-token", "tok",
"--llm-model", "gpt-4",
"--llm-provider", "aicore",
// aicore-client-id, aicore-client-secret, aicore-auth-url, aicore-api-url omitted
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_MissingAICoreCredentials")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit when aicore credentials are missing")
}
if !strings.Contains(string(out), "AI Core credentials") {
t.Errorf("expected error about AI Core credentials, got: %s", out)
}
}
// TestMainSubprocess_ConflictingPersonaFlags confirms that --persona and --persona-file
// cannot be used together.
func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--vcs-url", "https://gitea.example.com",
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-token", "tok",
"--llm-base-url", "https://api.example.com",
"--llm-api-key", "key",
"--llm-model", "gpt-4",
"--persona", "security",
"--persona-file", "custom.json",
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_ConflictingPersonaFlags")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with both --persona and --persona-file set")
}
if !strings.Contains(string(out), "mutually exclusive") {
t.Errorf("expected error about mutually exclusive flags, got: %s", out)
}
}
// TestMainSubprocess_DeprecatedGiteaURLEnv confirms that GITEA_URL env var still works
// as a deprecated fallback for VCS_URL.
func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
// Set required flags but omit --vcs-url; GITEA_URL should be picked up.
// The test will exit with an error after VCS init (no PR to fetch), but
// the deprecation warning must appear.
os.Args = []string{"review-bot",
// No --vcs-url: should fall back to GITEA_URL env var
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-token", "tok",
"--llm-base-url", "https://api.example.com",
"--llm-api-key", "key",
"--llm-model", "gpt-4",
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_DeprecatedGiteaURLEnv")
// Inject GITEA_URL but NOT VCS_URL.
env := append(cleanEnv(),
"TEST_SUBPROCESS_MAIN=1",
"GITEA_URL=https://gitea.example.com",
)
cmd.Env = env
out, _ := cmd.CombinedOutput()
// The process will fail (no real server), but the deprecation warning must appear.
if !strings.Contains(string(out), "deprecated") {
t.Errorf("expected deprecation warning for GITEA_URL, got: %s", out)
}
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.weiker.me/rodin/review-bot/review"
)
// runValidateDocmap implements the `review-bot validate-docmap` subcommand.
//
// It reads changed file paths from stdin (one per line, as produced by
// `git diff --name-only`), parses a doc-map YAML file, and performs two checks:
//
// 1. Coverage check: every changed file must be matched by at least one
// paths: glob in the docmap. Fails if any file is uncovered.
//
// 2. Stale-docs check: every docs: entry in the docmap must exist on disk
// (relative to --repo-root). Fails if any path is missing.
//
// Both checks always run — all failures are reported before exiting.
//
// Exit codes:
//
// 0 — clean (all files covered, all docs exist)
// 1 — one or more coverage or stale-doc failures
// 2 — usage error, missing flag, or YAML parse error
func runValidateDocmap(args []string) int {
fs := flag.NewFlagSet("validate-docmap", flag.ContinueOnError)
fs.SetOutput(errWriter)
docmapFlag := fs.String("docmap", "", "Path to doc-map YAML file (required)")
repoRootFlag := fs.String("repo-root", ".", "Repo root for resolving docs: paths (default: cwd)")
if err := fs.Parse(args); err != nil {
// flag.ContinueOnError already wrote the error to errWriter.
return 2
}
if *docmapFlag == "" {
fmt.Fprintln(errWriter, "Error: --docmap is required")
fmt.Fprintln(errWriter, "")
fmt.Fprintln(errWriter, "usage: review-bot validate-docmap --docmap <path> [--repo-root <dir>]")
fmt.Fprintln(errWriter, " Changed files are read from stdin, one per line.")
fmt.Fprintln(errWriter, " Example: git diff --name-only origin/main HEAD | review-bot validate-docmap --docmap .review-bot/doc-map.yml")
return 2
}
// Parse docmap YAML.
cfg, err := review.ParseDocMapConfig(*docmapFlag)
if err != nil {
fmt.Fprintf(errWriter, "Error: failed to parse docmap %q: %v\n", *docmapFlag, err)
return 2
}
// Read changed files from stdin.
changedFiles, err := readLines(os.Stdin)
if err != nil {
fmt.Fprintf(errWriter, "Error: failed to read stdin: %v\n", err)
return 2
}
failed := false
// --- Check 1: Coverage ---
var uncovered []string
for _, f := range changedFiles {
if !review.FileCoveredByDocMap(cfg, f) {
uncovered = append(uncovered, f)
}
}
if len(uncovered) > 0 {
failed = true
fmt.Fprintln(errWriter, "ERROR: changed files with no docmap coverage:")
for _, f := range uncovered {
fmt.Fprintf(errWriter, " %s\n", f)
}
}
// --- Check 2: Stale docs ---
repoRoot := filepath.Clean(*repoRootFlag)
staleDocs := checkStaleDocs(cfg, repoRoot)
if len(staleDocs) > 0 {
failed = true
fmt.Fprintln(errWriter, "ERROR: stale docmap docs: entries (paths do not exist):")
for _, d := range staleDocs {
fmt.Fprintf(errWriter, " %s\n", d)
}
}
if failed {
return 1
}
fmt.Fprintln(outWriter, "OK: docmap is valid")
return 0
}
// checkStaleDocs returns deduplicated docs: entries that do not exist under repoRoot.
func checkStaleDocs(cfg *review.DocMapConfig, repoRoot string) []string {
seen := make(map[string]struct{})
var stale []string
for _, mapping := range cfg.Mappings {
for _, docPath := range mapping.Docs {
if docPath == "" {
continue
}
if _, ok := seen[docPath]; ok {
continue
}
seen[docPath] = struct{}{}
fullPath := filepath.Join(repoRoot, filepath.FromSlash(docPath))
if _, err := os.Stat(fullPath); err != nil {
stale = append(stale, docPath)
}
}
}
return stale
}
// readLines reads all non-empty trimmed lines from r.
func readLines(r io.Reader) ([]string, error) {
scanner := bufio.NewScanner(r)
var lines []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
lines = append(lines, line)
}
}
return lines, scanner.Err()
}
+296
View File
@@ -0,0 +1,296 @@
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)
}
}
+57
View File
@@ -125,3 +125,60 @@ func TestRunValidateURL_WithCapture(t *testing.T) {
t.Errorf("expected error about https in stderr, got %q", errBuf.String())
}
}
// TestIsValidateError_Nil confirms that isValidateError returns false for a nil error.
func TestIsValidateError_Nil(t *testing.T) {
var ve *validateError
if isValidateError(nil, &ve) {
t.Error("isValidateError(nil, ...) should return false")
}
}
// TestValidateURL_EmptyHost confirms that a URL with no hostname returns a code-2 error.
func TestValidateURL_EmptyHost(t *testing.T) {
// "https://" parses fine but has no hostname.
err := validateURL("https://")
if err == nil {
t.Fatal("expected error for URL with no host, got nil")
}
var ve *validateError
if !isValidateError(err, &ve) {
t.Fatalf("expected *validateError, got %T: %v", err, err)
}
if ve.code != 2 {
t.Errorf("expected code 2, got %d (msg=%s)", ve.code, ve.message)
}
if !strings.Contains(ve.message, "no host") {
t.Errorf("expected 'no host' in error message, got %q", ve.message)
}
}
// TestRunValidateURL_Success confirms that a resolvable public URL prints "OK" and returns 0.
// This test requires external DNS; it is skipped in environments without network access.
func TestRunValidateURL_Success(t *testing.T) {
// Pre-check: validate that DNS is available before exercising the success path.
err := validateURL("https://example.com/")
if err != nil {
t.Skipf("skipping success-path test: DNS unavailable or example.com blocked (%v)", err)
}
var outBuf, errBuf bytes.Buffer
origOut, origErr := outWriter, errWriter
outWriter = &outBuf
errWriter = &errBuf
defer func() {
outWriter = origOut
errWriter = origErr
}()
code := runValidateURL([]string{"https://example.com/"})
if code != 0 {
t.Errorf("expected exit code 0 for safe URL, got %d (stderr: %s)", code, errBuf.String())
}
if !strings.Contains(outBuf.String(), "OK:") {
t.Errorf("expected 'OK:' in stdout, got %q", outBuf.String())
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr for safe URL, got %q", errBuf.String())
}
}
+12
View File
@@ -66,6 +66,18 @@ func ParseDocMapConfig(localPath string) (*DocMapConfig, error) {
return &cfg, nil
}
// FileCoveredByDocMap reports whether at least one paths: glob in any mapping
// of cfg matches the given file path. It is used by static validation tooling
// (e.g. the validate-docmap subcommand) to check per-file docmap coverage.
func FileCoveredByDocMap(cfg *DocMapConfig, file string) bool {
for _, mapping := range cfg.Mappings {
if mappingMatches(mapping.Paths, []string{file}) {
return true
}
}
return false
}
// MatchDocs returns deduplicated doc paths for the given changed file paths.
// A mapping matches if any of its path globs matches any of the changed files.
func MatchDocs(cfg *DocMapConfig, changedFiles []string) []string {
+49
View File
@@ -436,3 +436,52 @@ func writeTempYAML(t *testing.T, content string) string {
}
return filepath.Clean(f.Name())
}
// ============================================================
// FileCoveredByDocMap
// ============================================================
func TestFileCoveredByDocMap(t *testing.T) {
cfg := &DocMapConfig{
Mappings: []DocMapping{
{
Paths: []string{"lib/foo/**", "lib/bar/*.go"},
Docs: []string{"docs/foo.md"},
},
{
Paths: []string{"cmd/**"},
Docs: []string{"docs/cmd.md"},
},
},
}
cases := []struct {
file string
covered bool
}{
{"lib/foo/baz.ex", true},
{"lib/foo/sub/deep.ex", true},
{"lib/bar/util.go", true},
{"lib/bar/sub/util.go", false}, // *.go only matches one level
{"cmd/main.go", true},
{"cmd/sub/main.go", true},
{"internal/secret.go", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.file, func(t *testing.T) {
got := FileCoveredByDocMap(cfg, tc.file)
if got != tc.covered {
t.Errorf("FileCoveredByDocMap(%q) = %v, want %v", tc.file, got, tc.covered)
}
})
}
}
func TestFileCoveredByDocMap_EmptyConfig(t *testing.T) {
cfg := &DocMapConfig{}
if FileCoveredByDocMap(cfg, "lib/foo/bar.go") {
t.Error("expected false for empty config, got true")
}
}