Implements Phase 1 of bd-56 (Repair Commands & AI-Assisted Tooling): New commands: - bd repair-deps: Find and fix orphaned dependency references - bd detect-pollution: Detect test issues using pattern matching - bd validate: Comprehensive health check (orphans, duplicates, pollution) Features: - JSON output support for all commands - Safe deletion with backup for detect-pollution - Auto-fix support for orphaned dependencies - Direct storage access (requires BEADS_NO_DAEMON=1) Closes bd-56 (Phase 1 complete) Related: bd-103, bd-105, bd-106 Amp-Thread-ID: https://ampcode.com/threads/T-5822c6d2-d645-4043-9a8d-3c51ac93bbb7 Co-authored-by: Amp <amp@ampcode.com>
313 lines
8.1 KiB
Go
313 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
var validateCmd = &cobra.Command{
|
|
Use: "validate",
|
|
Short: "Run comprehensive database health checks",
|
|
Long: `Run all validation checks to ensure database integrity:
|
|
- Orphaned dependencies (references to deleted issues)
|
|
- Duplicate issues (identical content)
|
|
- Test pollution (leaked test issues)
|
|
- Git merge conflicts in JSONL
|
|
|
|
Example:
|
|
bd validate # Run all checks
|
|
bd validate --fix-all # Auto-fix all issues
|
|
bd validate --checks=orphans,dupes # Run specific checks
|
|
bd validate --json # Output in JSON format`,
|
|
Run: func(cmd *cobra.Command, _ []string) {
|
|
// Check daemon mode - not supported yet (uses direct storage access)
|
|
if daemonClient != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: validate command not yet supported in daemon mode\n")
|
|
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon validate\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
fixAll, _ := cmd.Flags().GetBool("fix-all")
|
|
checksFlag, _ := cmd.Flags().GetString("checks")
|
|
|
|
ctx := context.Background()
|
|
|
|
// Determine which checks to run
|
|
var checks []string
|
|
if checksFlag == "" {
|
|
checks = []string{"orphans", "duplicates", "pollution"}
|
|
} else {
|
|
checks = strings.Split(checksFlag, ",")
|
|
}
|
|
|
|
results := validationResults{
|
|
checks: make(map[string]checkResult),
|
|
}
|
|
|
|
// Run each check
|
|
for _, check := range checks {
|
|
switch check {
|
|
case "orphans":
|
|
results.checks["orphans"] = validateOrphanedDeps(ctx, fixAll)
|
|
case "duplicates", "dupes":
|
|
results.checks["duplicates"] = validateDuplicates(ctx, fixAll)
|
|
case "pollution":
|
|
results.checks["pollution"] = validatePollution(ctx, fixAll)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "Unknown check: %s\n", check)
|
|
}
|
|
}
|
|
|
|
// Output results
|
|
if jsonOutput {
|
|
outputJSON(results.toJSON())
|
|
} else {
|
|
results.print(fixAll)
|
|
}
|
|
|
|
// Exit with error code if issues found
|
|
if results.hasIssues() {
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
type checkResult struct {
|
|
name string
|
|
issueCount int
|
|
fixedCount int
|
|
err error
|
|
suggestions []string
|
|
}
|
|
|
|
type validationResults struct {
|
|
checks map[string]checkResult
|
|
}
|
|
|
|
func (r *validationResults) hasIssues() bool {
|
|
for _, result := range r.checks {
|
|
if result.issueCount > 0 && result.fixedCount < result.issueCount {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *validationResults) toJSON() map[string]interface{} {
|
|
output := map[string]interface{}{
|
|
"checks": map[string]interface{}{},
|
|
}
|
|
|
|
totalIssues := 0
|
|
totalFixed := 0
|
|
|
|
for name, result := range r.checks {
|
|
output["checks"].(map[string]interface{})[name] = map[string]interface{}{
|
|
"issue_count": result.issueCount,
|
|
"fixed_count": result.fixedCount,
|
|
"error": result.err,
|
|
"suggestions": result.suggestions,
|
|
}
|
|
totalIssues += result.issueCount
|
|
totalFixed += result.fixedCount
|
|
}
|
|
|
|
output["total_issues"] = totalIssues
|
|
output["total_fixed"] = totalFixed
|
|
output["healthy"] = totalIssues == 0 || totalIssues == totalFixed
|
|
|
|
return output
|
|
}
|
|
|
|
func (r *validationResults) print(fixAll bool) {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
red := color.New(color.FgRed).SprintFunc()
|
|
|
|
fmt.Println("\nValidation Results:")
|
|
fmt.Println("===================")
|
|
|
|
totalIssues := 0
|
|
totalFixed := 0
|
|
|
|
for name, result := range r.checks {
|
|
prefix := "✓"
|
|
colorFunc := green
|
|
|
|
if result.err != nil {
|
|
prefix = "✗"
|
|
colorFunc = red
|
|
fmt.Printf("%s %s: ERROR - %v\n", colorFunc(prefix), name, result.err)
|
|
} else if result.issueCount > 0 {
|
|
prefix = "⚠"
|
|
colorFunc = yellow
|
|
if result.fixedCount > 0 {
|
|
fmt.Printf("%s %s: %d found, %d fixed\n", colorFunc(prefix), name, result.issueCount, result.fixedCount)
|
|
} else {
|
|
fmt.Printf("%s %s: %d found\n", colorFunc(prefix), name, result.issueCount)
|
|
}
|
|
} else {
|
|
fmt.Printf("%s %s: OK\n", colorFunc(prefix), name)
|
|
}
|
|
|
|
totalIssues += result.issueCount
|
|
totalFixed += result.fixedCount
|
|
}
|
|
|
|
fmt.Println()
|
|
|
|
if totalIssues == 0 {
|
|
fmt.Printf("%s Database is healthy!\n", green("✓"))
|
|
} else if totalFixed == totalIssues {
|
|
fmt.Printf("%s Fixed all %d issues\n", green("✓"), totalFixed)
|
|
} else {
|
|
remaining := totalIssues - totalFixed
|
|
fmt.Printf("%s Found %d issues", yellow("⚠"), totalIssues)
|
|
if totalFixed > 0 {
|
|
fmt.Printf(" (fixed %d, %d remaining)", totalFixed, remaining)
|
|
}
|
|
fmt.Println()
|
|
|
|
// Print suggestions
|
|
fmt.Println("\nRecommendations:")
|
|
for _, result := range r.checks {
|
|
for _, suggestion := range result.suggestions {
|
|
fmt.Printf(" - %s\n", suggestion)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateOrphanedDeps(ctx context.Context, fix bool) checkResult {
|
|
result := checkResult{name: "orphaned dependencies"}
|
|
|
|
// Get all issues
|
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
result.err = err
|
|
return result
|
|
}
|
|
|
|
// Build ID existence map
|
|
existingIDs := make(map[string]bool)
|
|
for _, issue := range allIssues {
|
|
existingIDs[issue.ID] = true
|
|
}
|
|
|
|
// Find orphaned dependencies
|
|
type orphanedDep struct {
|
|
issueID string
|
|
orphanedID string
|
|
}
|
|
var orphaned []orphanedDep
|
|
|
|
for _, issue := range allIssues {
|
|
for _, dep := range issue.Dependencies {
|
|
if !existingIDs[dep.DependsOnID] {
|
|
orphaned = append(orphaned, orphanedDep{
|
|
issueID: issue.ID,
|
|
orphanedID: dep.DependsOnID,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
result.issueCount = len(orphaned)
|
|
|
|
if fix && len(orphaned) > 0 {
|
|
// Group by issue
|
|
orphansByIssue := make(map[string][]string)
|
|
for _, o := range orphaned {
|
|
orphansByIssue[o.issueID] = append(orphansByIssue[o.issueID], o.orphanedID)
|
|
}
|
|
|
|
// Fix each issue
|
|
for issueID, orphanedIDs := range orphansByIssue {
|
|
for _, orphanedID := range orphanedIDs {
|
|
if err := store.RemoveDependency(ctx, issueID, orphanedID, actor); err == nil {
|
|
result.fixedCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.fixedCount > 0 {
|
|
markDirtyAndScheduleFlush()
|
|
}
|
|
}
|
|
|
|
if result.issueCount > result.fixedCount {
|
|
result.suggestions = append(result.suggestions, "Run 'bd repair-deps --fix' to remove orphaned dependencies")
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func validateDuplicates(ctx context.Context, fix bool) checkResult {
|
|
result := checkResult{name: "duplicates"}
|
|
|
|
// Get all issues
|
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
result.err = err
|
|
return result
|
|
}
|
|
|
|
// Find duplicates
|
|
duplicateGroups := findDuplicateGroups(allIssues)
|
|
|
|
// Count total duplicate issues (excluding one canonical per group)
|
|
for _, group := range duplicateGroups {
|
|
result.issueCount += len(group) - 1
|
|
}
|
|
|
|
if fix && len(duplicateGroups) > 0 {
|
|
// Note: Auto-merge is complex and requires user review
|
|
// We don't auto-fix duplicates, just report them
|
|
result.suggestions = append(result.suggestions,
|
|
fmt.Sprintf("Run 'bd duplicates --auto-merge' to merge %d duplicate groups", len(duplicateGroups)))
|
|
} else if result.issueCount > 0 {
|
|
result.suggestions = append(result.suggestions,
|
|
fmt.Sprintf("Run 'bd duplicates' to review %d duplicate groups", len(duplicateGroups)))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func validatePollution(ctx context.Context, fix bool) checkResult {
|
|
result := checkResult{name: "test pollution"}
|
|
|
|
// Get all issues
|
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
result.err = err
|
|
return result
|
|
}
|
|
|
|
// Detect pollution
|
|
polluted := detectTestPollution(allIssues)
|
|
result.issueCount = len(polluted)
|
|
|
|
if fix && len(polluted) > 0 {
|
|
// Note: Deleting issues is destructive, we just suggest it
|
|
result.suggestions = append(result.suggestions,
|
|
fmt.Sprintf("Run 'bd detect-pollution --clean' to delete %d test issues", len(polluted)))
|
|
} else if result.issueCount > 0 {
|
|
result.suggestions = append(result.suggestions,
|
|
fmt.Sprintf("Run 'bd detect-pollution' to review %d potential test issues", len(polluted)))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func init() {
|
|
validateCmd.Flags().Bool("fix-all", false, "Auto-fix all fixable issues")
|
|
validateCmd.Flags().String("checks", "", "Comma-separated list of checks (orphans,duplicates,pollution)")
|
|
rootCmd.AddCommand(validateCmd)
|
|
}
|