DOCTOR IMPROVEMENTS: visual improvements/grouping + add comprehensive tests + fix gosec warnings (#656)

* test(doctor): add comprehensive tests for fix and check functions

Add edge case tests, e2e tests, and improve test coverage for:
- database_test.go: database integrity and sync checks
- git_test.go: git hooks, merge driver, sync branch tests
- gitignore_test.go: gitignore validation
- prefix_test.go: ID prefix handling
- fix/fix_test.go: fix operations
- fix/e2e_test.go: end-to-end fix scenarios
- fix/fix_edge_cases_test.go: edge case handling

* docs: add testing philosophy and anti-patterns guide

- Create TESTING_PHILOSOPHY.md covering test pyramid, priority matrix,
  what NOT to test, and 5 anti-patterns with code examples
- Add cross-reference from README_TESTING.md
- Document beads-specific guidance (well-covered areas vs gaps)
- Include target metrics (test-to-code ratio, execution time targets)

* chore: revert .beads/ to upstream/main state

* refactor(doctor): add category grouping and Ayu theme colors

- Add Category field to DoctorCheck for organizing checks by type
- Define category constants: Core, Git, Runtime, Data, Integration, Metadata
- Update thanks command to use shared Ayu color palette from internal/ui
- Simplify test fixtures by removing redundant test cases

* fix(doctor): prevent test fork bomb and fix test failures

- Add ErrTestBinary guard in getBdBinary() to prevent tests from
  recursively executing the test binary when calling bd subcommands
- Update claude_test.go to use new check names (CLI Availability,
  Prime Documentation)
- Fix syncbranch test path comparison by resolving symlinks
  (/var vs /private/var on macOS)
- Fix permissions check to use exact comparison instead of bitmask
- Fix UntrackedJSONL to use git commit --only to preserve staged changes
- Fix MergeDriver edge case test by making both .git dir and config
  read-only
- Add skipIfTestBinary helper for E2E tests that need real bd binary

* test(doctor): skip read-only config test in CI environments

GitHub Actions containers may have CAP_DAC_OVERRIDE or similar
capabilities that allow writing to read-only files, causing
the test to fail. Skip the test when CI=true or GITHUB_ACTIONS=true.
This commit is contained in:
Ryan
2025-12-20 03:10:06 -08:00
committed by GitHub
parent f3b0fb780f
commit 3c08e5eb9d
23 changed files with 6248 additions and 404 deletions

View File

@@ -19,6 +19,7 @@ import (
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/ui"
)
// Status constants for doctor checks
@@ -29,11 +30,12 @@ const (
)
type doctorCheck struct {
Name string `json:"name"`
Status string `json:"status"` // statusOK, statusWarning, or statusError
Message string `json:"message"`
Detail string `json:"detail,omitempty"` // Additional detail like storage type
Fix string `json:"fix,omitempty"`
Name string `json:"name"`
Status string `json:"status"` // statusOK, statusWarning, or statusError
Message string `json:"message"`
Detail string `json:"detail,omitempty"` // Additional detail like storage type
Fix string `json:"fix,omitempty"`
Category string `json:"category,omitempty"` // category for grouping in output
}
type doctorResult struct {
@@ -539,19 +541,19 @@ func runDiagnostics(path string) doctorResult {
}
// Check 1: Installation (.beads/ directory)
installCheck := convertDoctorCheck(doctor.CheckInstallation(path))
installCheck := convertWithCategory(doctor.CheckInstallation(path), doctor.CategoryCore)
result.Checks = append(result.Checks, installCheck)
if installCheck.Status != statusOK {
result.OverallOK = false
}
// Check Git Hooks early (even if .beads/ doesn't exist yet)
hooksCheck := convertDoctorCheck(doctor.CheckGitHooks())
hooksCheck := convertWithCategory(doctor.CheckGitHooks(), doctor.CategoryGit)
result.Checks = append(result.Checks, hooksCheck)
// Don't fail overall check for missing hooks, just warn
// Check sync-branch hook compatibility (issue #532)
syncBranchHookCheck := convertDoctorCheck(doctor.CheckSyncBranchHookCompatibility(path))
syncBranchHookCheck := convertWithCategory(doctor.CheckSyncBranchHookCompatibility(path), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchHookCheck)
if syncBranchHookCheck.Status == statusError {
result.OverallOK = false
@@ -564,171 +566,171 @@ func runDiagnostics(path string) doctorResult {
// Check 1a: Fresh clone detection (bd-4ew)
// Must come early - if this is a fresh clone, other checks may be misleading
freshCloneCheck := convertDoctorCheck(doctor.CheckFreshClone(path))
freshCloneCheck := convertWithCategory(doctor.CheckFreshClone(path), doctor.CategoryCore)
result.Checks = append(result.Checks, freshCloneCheck)
if freshCloneCheck.Status == statusWarning || freshCloneCheck.Status == statusError {
result.OverallOK = false
}
// Check 2: Database version
dbCheck := convertDoctorCheck(doctor.CheckDatabaseVersion(path, Version))
dbCheck := convertWithCategory(doctor.CheckDatabaseVersion(path, Version), doctor.CategoryCore)
result.Checks = append(result.Checks, dbCheck)
if dbCheck.Status == statusError {
result.OverallOK = false
}
// Check 2a: Schema compatibility (bd-ckvw)
schemaCheck := convertDoctorCheck(doctor.CheckSchemaCompatibility(path))
schemaCheck := convertWithCategory(doctor.CheckSchemaCompatibility(path), doctor.CategoryCore)
result.Checks = append(result.Checks, schemaCheck)
if schemaCheck.Status == statusError {
result.OverallOK = false
}
// Check 2b: Database integrity (bd-2au)
integrityCheck := convertDoctorCheck(doctor.CheckDatabaseIntegrity(path))
integrityCheck := convertWithCategory(doctor.CheckDatabaseIntegrity(path), doctor.CategoryCore)
result.Checks = append(result.Checks, integrityCheck)
if integrityCheck.Status == statusError {
result.OverallOK = false
}
// Check 3: ID format (hash vs sequential)
idCheck := convertDoctorCheck(doctor.CheckIDFormat(path))
idCheck := convertWithCategory(doctor.CheckIDFormat(path), doctor.CategoryCore)
result.Checks = append(result.Checks, idCheck)
if idCheck.Status == statusWarning {
result.OverallOK = false
}
// Check 4: CLI version (GitHub)
versionCheck := convertDoctorCheck(doctor.CheckCLIVersion(Version))
versionCheck := convertWithCategory(doctor.CheckCLIVersion(Version), doctor.CategoryCore)
result.Checks = append(result.Checks, versionCheck)
// Don't fail overall check for outdated CLI, just warn
// Check 4.5: Claude plugin version (if running in Claude Code)
pluginCheck := convertDoctorCheck(doctor.CheckClaudePlugin())
pluginCheck := convertWithCategory(doctor.CheckClaudePlugin(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, pluginCheck)
// Don't fail overall check for outdated plugin, just warn
// Check 5: Multiple database files
multiDBCheck := convertDoctorCheck(doctor.CheckMultipleDatabases(path))
multiDBCheck := convertWithCategory(doctor.CheckMultipleDatabases(path), doctor.CategoryData)
result.Checks = append(result.Checks, multiDBCheck)
if multiDBCheck.Status == statusWarning || multiDBCheck.Status == statusError {
result.OverallOK = false
}
// Check 6: Multiple JSONL files (excluding merge artifacts)
jsonlCheck := convertDoctorCheck(doctor.CheckLegacyJSONLFilename(path))
jsonlCheck := convertWithCategory(doctor.CheckLegacyJSONLFilename(path), doctor.CategoryData)
result.Checks = append(result.Checks, jsonlCheck)
if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError {
result.OverallOK = false
}
// Check 6a: Legacy JSONL config (bd-6xd: migrate beads.jsonl to issues.jsonl)
legacyConfigCheck := convertDoctorCheck(doctor.CheckLegacyJSONLConfig(path))
legacyConfigCheck := convertWithCategory(doctor.CheckLegacyJSONLConfig(path), doctor.CategoryData)
result.Checks = append(result.Checks, legacyConfigCheck)
// Don't fail overall check for legacy config, just warn
// Check 7: Database/JSONL configuration mismatch
configCheck := convertDoctorCheck(doctor.CheckDatabaseConfig(path))
configCheck := convertWithCategory(doctor.CheckDatabaseConfig(path), doctor.CategoryData)
result.Checks = append(result.Checks, configCheck)
if configCheck.Status == statusWarning || configCheck.Status == statusError {
result.OverallOK = false
}
// Check 7a: Configuration value validation (bd-alz)
configValuesCheck := convertDoctorCheck(doctor.CheckConfigValues(path))
configValuesCheck := convertWithCategory(doctor.CheckConfigValues(path), doctor.CategoryData)
result.Checks = append(result.Checks, configValuesCheck)
// Don't fail overall check for config value warnings, just warn
// Check 8: Daemon health
daemonCheck := convertDoctorCheck(doctor.CheckDaemonStatus(path, Version))
daemonCheck := convertWithCategory(doctor.CheckDaemonStatus(path, Version), doctor.CategoryRuntime)
result.Checks = append(result.Checks, daemonCheck)
if daemonCheck.Status == statusWarning || daemonCheck.Status == statusError {
result.OverallOK = false
}
// Check 9: Database-JSONL sync
syncCheck := convertDoctorCheck(doctor.CheckDatabaseJSONLSync(path))
syncCheck := convertWithCategory(doctor.CheckDatabaseJSONLSync(path), doctor.CategoryData)
result.Checks = append(result.Checks, syncCheck)
if syncCheck.Status == statusWarning || syncCheck.Status == statusError {
result.OverallOK = false
}
// Check 9: Permissions
permCheck := convertDoctorCheck(doctor.CheckPermissions(path))
permCheck := convertWithCategory(doctor.CheckPermissions(path), doctor.CategoryCore)
result.Checks = append(result.Checks, permCheck)
if permCheck.Status == statusError {
result.OverallOK = false
}
// Check 10: Dependency cycles
cycleCheck := convertDoctorCheck(doctor.CheckDependencyCycles(path))
cycleCheck := convertWithCategory(doctor.CheckDependencyCycles(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, cycleCheck)
if cycleCheck.Status == statusError || cycleCheck.Status == statusWarning {
result.OverallOK = false
}
// Check 11: Claude integration
claudeCheck := convertDoctorCheck(doctor.CheckClaude())
claudeCheck := convertWithCategory(doctor.CheckClaude(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, claudeCheck)
// Don't fail overall check for missing Claude integration, just warn
// Check 11a: bd in PATH (needed for Claude hooks to work)
bdPathCheck := convertDoctorCheck(doctor.CheckBdInPath())
bdPathCheck := convertWithCategory(doctor.CheckBdInPath(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, bdPathCheck)
// Don't fail overall check for missing bd in PATH, just warn
// Check 11b: Documentation bd prime references match installed version
bdPrimeDocsCheck := convertDoctorCheck(doctor.CheckDocumentationBdPrimeReference(path))
bdPrimeDocsCheck := convertWithCategory(doctor.CheckDocumentationBdPrimeReference(path), doctor.CategoryIntegration)
result.Checks = append(result.Checks, bdPrimeDocsCheck)
// Don't fail overall check for doc mismatch, just warn
// Check 12: Agent documentation presence
agentDocsCheck := convertDoctorCheck(doctor.CheckAgentDocumentation(path))
agentDocsCheck := convertWithCategory(doctor.CheckAgentDocumentation(path), doctor.CategoryIntegration)
result.Checks = append(result.Checks, agentDocsCheck)
// Don't fail overall check for missing docs, just warn
// Check 13: Legacy beads slash commands in documentation
legacyDocsCheck := convertDoctorCheck(doctor.CheckLegacyBeadsSlashCommands(path))
legacyDocsCheck := convertWithCategory(doctor.CheckLegacyBeadsSlashCommands(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, legacyDocsCheck)
// Don't fail overall check for legacy docs, just warn
// Check 14: Gitignore up to date
gitignoreCheck := convertDoctorCheck(doctor.CheckGitignore())
gitignoreCheck := convertWithCategory(doctor.CheckGitignore(), doctor.CategoryGit)
result.Checks = append(result.Checks, gitignoreCheck)
// Don't fail overall check for gitignore, just warn
// Check 15: Git merge driver configuration
mergeDriverCheck := convertDoctorCheck(doctor.CheckMergeDriver(path))
mergeDriverCheck := convertWithCategory(doctor.CheckMergeDriver(path), doctor.CategoryGit)
result.Checks = append(result.Checks, mergeDriverCheck)
// Don't fail overall check for merge driver, just warn
// Check 16: Metadata.json version tracking (bd-u4sb)
metadataCheck := convertDoctorCheck(doctor.CheckMetadataVersionTracking(path, Version))
metadataCheck := convertWithCategory(doctor.CheckMetadataVersionTracking(path, Version), doctor.CategoryMetadata)
result.Checks = append(result.Checks, metadataCheck)
// Don't fail overall check for metadata, just warn
// Check 17: Sync branch configuration (bd-rsua)
syncBranchCheck := convertDoctorCheck(doctor.CheckSyncBranchConfig(path))
syncBranchCheck := convertWithCategory(doctor.CheckSyncBranchConfig(path), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchCheck)
// Don't fail overall check for missing sync.branch, just warn
// Check 17a: Sync branch health (bd-6rf)
syncBranchHealthCheck := convertDoctorCheck(doctor.CheckSyncBranchHealth(path))
syncBranchHealthCheck := convertWithCategory(doctor.CheckSyncBranchHealth(path), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchHealthCheck)
// Don't fail overall check for sync branch health, just warn
// Check 18: Deletions manifest (legacy, now replaced by tombstones)
deletionsCheck := convertDoctorCheck(doctor.CheckDeletionsManifest(path))
deletionsCheck := convertWithCategory(doctor.CheckDeletionsManifest(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, deletionsCheck)
// Don't fail overall check for missing deletions manifest, just warn
// Check 19: Tombstones health (bd-s3v)
tombstonesCheck := convertDoctorCheck(doctor.CheckTombstones(path))
tombstonesCheck := convertWithCategory(doctor.CheckTombstones(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, tombstonesCheck)
// Don't fail overall check for tombstone issues, just warn
// Check 20: Untracked .beads/*.jsonl files (bd-pbj)
untrackedCheck := convertDoctorCheck(doctor.CheckUntrackedBeadsFiles(path))
untrackedCheck := convertWithCategory(doctor.CheckUntrackedBeadsFiles(path), doctor.CategoryData)
result.Checks = append(result.Checks, untrackedCheck)
// Don't fail overall check for untracked files, just warn
@@ -738,14 +740,22 @@ func runDiagnostics(path string) doctorResult {
// convertDoctorCheck converts doctor package check to main package check
func convertDoctorCheck(dc doctor.DoctorCheck) doctorCheck {
return doctorCheck{
Name: dc.Name,
Status: dc.Status,
Message: dc.Message,
Detail: dc.Detail,
Fix: dc.Fix,
Name: dc.Name,
Status: dc.Status,
Message: dc.Message,
Detail: dc.Detail,
Fix: dc.Fix,
Category: dc.Category,
}
}
// convertWithCategory converts a doctor check and sets its category
func convertWithCategory(dc doctor.DoctorCheck, category string) doctorCheck {
check := convertDoctorCheck(dc)
check.Category = category
return check
}
// exportDiagnostics writes the doctor result to a JSON file (bd-9cc)
func exportDiagnostics(result doctorResult, outputPath string) error {
// #nosec G304 - outputPath is a user-provided flag value for file generation
@@ -765,63 +775,117 @@ func exportDiagnostics(result doctorResult, outputPath string) error {
}
func printDiagnostics(result doctorResult) {
// Print header
fmt.Println("\nDiagnostics")
// Print header with version
fmt.Printf("\nbd doctor v%s\n\n", result.CLIVersion)
// Print each check with tree formatting
for i, check := range result.Checks {
// Determine prefix
prefix := "├"
if i == len(result.Checks)-1 {
prefix = "└"
}
// Format status indicator
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ""
case statusWarning:
statusIcon = color.YellowString(" ⚠")
case statusError:
statusIcon = color.RedString(" ✗")
}
// Print main check line
fmt.Printf(" %s %s: %s%s\n", prefix, check.Name, check.Message, statusIcon)
// Print detail if present (indented under the check)
if check.Detail != "" {
detailPrefix := "│"
if i == len(result.Checks)-1 {
detailPrefix = " "
}
fmt.Printf(" %s %s\n", detailPrefix, color.New(color.Faint).Sprint(check.Detail))
}
}
fmt.Println()
// Print warnings/errors with fixes
hasIssues := false
// Group checks by category
checksByCategory := make(map[string][]doctorCheck)
for _, check := range result.Checks {
if check.Status != statusOK && check.Fix != "" {
if !hasIssues {
hasIssues = true
}
switch check.Status {
case statusWarning:
color.Yellow("⚠ Warning: %s\n", check.Message)
case statusError:
color.Red("✗ Error: %s\n", check.Message)
}
fmt.Printf(" Fix: %s\n\n", check.Fix)
cat := check.Category
if cat == "" {
cat = "Other"
}
checksByCategory[cat] = append(checksByCategory[cat], check)
}
if !hasIssues {
// Track counts
var passCount, warnCount, failCount int
var warnings []doctorCheck
// Print checks by category in defined order
for _, category := range doctor.CategoryOrder {
checks, exists := checksByCategory[category]
if !exists || len(checks) == 0 {
continue
}
// Print category header
fmt.Println(ui.RenderCategory(category))
// Print each check in this category
for _, check := range checks {
// Determine status icon
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ui.RenderPassIcon()
passCount++
case statusWarning:
statusIcon = ui.RenderWarnIcon()
warnCount++
warnings = append(warnings, check)
case statusError:
statusIcon = ui.RenderFailIcon()
failCount++
warnings = append(warnings, check)
}
// Print check line: icon + name + message
fmt.Printf(" %s %s", statusIcon, check.Name)
if check.Message != "" {
fmt.Printf("%s", ui.RenderMuted(" "+check.Message))
}
fmt.Println()
// Print detail if present (indented)
if check.Detail != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(check.Detail))
}
}
fmt.Println()
}
// Print any checks without a category
if otherChecks, exists := checksByCategory["Other"]; exists && len(otherChecks) > 0 {
fmt.Println(ui.RenderCategory("Other"))
for _, check := range otherChecks {
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ui.RenderPassIcon()
passCount++
case statusWarning:
statusIcon = ui.RenderWarnIcon()
warnCount++
warnings = append(warnings, check)
case statusError:
statusIcon = ui.RenderFailIcon()
failCount++
warnings = append(warnings, check)
}
fmt.Printf(" %s %s", statusIcon, check.Name)
if check.Message != "" {
fmt.Printf("%s", ui.RenderMuted(" "+check.Message))
}
fmt.Println()
if check.Detail != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(check.Detail))
}
}
fmt.Println()
}
// Print summary line
fmt.Println(ui.RenderSeparator())
summary := fmt.Sprintf("%s %d passed %s %d warnings %s %d failed",
ui.RenderPassIcon(), passCount,
ui.RenderWarnIcon(), warnCount,
ui.RenderFailIcon(), failCount,
)
fmt.Println(summary)
// Print warnings/errors section with fixes
if len(warnings) > 0 {
fmt.Println()
fmt.Println(ui.RenderWarn(ui.IconWarn + " WARNINGS"))
for _, check := range warnings {
fmt.Printf(" %s: %s\n", check.Name, check.Message)
if check.Fix != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), check.Fix)
}
}
} else {
fmt.Println()
color.Green("✓ All checks passed\n")
}
}