diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index c5444dee..3c9a3084 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -59,6 +59,7 @@ var ( checkHealthMode bool doctorCheckFlag string // bd-kff0: run specific check (e.g., "pollution") doctorClean bool // bd-kff0: for pollution check, delete detected issues + doctorDeep bool // bd-cwpl: full graph integrity validation ) // ConfigKeyHintsDoctor is the config key for suppressing doctor hints @@ -105,6 +106,16 @@ Specific Check Mode (--check): Run a specific check in detail. Available checks: - pollution: Detect and optionally clean test issues from database +Deep Validation Mode (--deep): + Validate full graph integrity. May be slow on large databases. + Additional checks: + - Parent consistency: All parent-child deps point to existing issues + - Dependency integrity: All deps reference valid issues + - Epic completeness: Find epics ready to close (all children closed) + - Agent bead integrity: Agent beads have valid state values + - Mail thread integrity: Thread IDs reference existing issues + - Molecule integrity: Molecules have valid parent-child structures + Examples: bd doctor # Check current directory bd doctor /path/to/repo # Check specific repository @@ -117,7 +128,8 @@ Examples: bd doctor --perf # Performance diagnostics bd doctor --output diagnostics.json # Export diagnostics to file bd doctor --check=pollution # Show potential test issues - bd doctor --check=pollution --clean # Delete test issues (with confirmation)`, + bd doctor --check=pollution --clean # Delete test issues (with confirmation) + bd doctor --deep # Full graph integrity validation (bd-cwpl)`, Run: func(cmd *cobra.Command, args []string) { // Use global jsonOutput set by PersistentPreRun @@ -159,6 +171,12 @@ Examples: } } + // Run deep validation if --deep flag is set (bd-cwpl) + if doctorDeep { + runDeepValidation(absPath) + return + } + // Run diagnostics result := runDiagnostics(absPath) @@ -592,6 +610,30 @@ func runCheckHealth(path string) { // Silent exit on success } +// runDeepValidation runs full graph integrity validation (bd-cwpl) +func runDeepValidation(path string) { + // Show warning about potential slowness + fmt.Println("Running deep validation (may be slow on large databases)...") + fmt.Println() + + result := doctor.RunDeepValidation(path) + + if jsonOutput { + jsonBytes, err := doctor.DeepValidationResultJSON(result) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println(string(jsonBytes)) + } else { + doctor.PrintDeepValidationResult(result) + } + + if !result.OverallOK { + os.Exit(1) + } +} + // printCheckHealthHint prints the health check hint and exits with error. func printCheckHealthHint(issues []string) { fmt.Fprintf(os.Stderr, "šŸ’” bd doctor recommends a health check:\n") @@ -1249,4 +1291,5 @@ func init() { doctorCmd.Flags().StringVarP(&doctorOutput, "output", "o", "", "Export diagnostics to JSON file (bd-9cc)") doctorCmd.Flags().StringVar(&doctorCheckFlag, "check", "", "Run specific check in detail (e.g., 'pollution')") doctorCmd.Flags().BoolVar(&doctorClean, "clean", false, "For pollution check: delete detected test issues") + doctorCmd.Flags().BoolVar(&doctorDeep, "deep", false, "Validate full graph integrity (bd-cwpl)") } diff --git a/cmd/bd/doctor/deep.go b/cmd/bd/doctor/deep.go new file mode 100644 index 00000000..fe227e51 --- /dev/null +++ b/cmd/bd/doctor/deep.go @@ -0,0 +1,550 @@ +// Package doctor provides health check and repair functionality for beads. +package doctor + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/types" +) + +// DeepValidationResult holds all deep validation check results +type DeepValidationResult struct { + ParentConsistency DoctorCheck `json:"parent_consistency"` + DependencyIntegrity DoctorCheck `json:"dependency_integrity"` + EpicCompleteness DoctorCheck `json:"epic_completeness"` + AgentBeadIntegrity DoctorCheck `json:"agent_bead_integrity"` + MailThreadIntegrity DoctorCheck `json:"mail_thread_integrity"` + MoleculeIntegrity DoctorCheck `json:"molecule_integrity"` + AllChecks []DoctorCheck `json:"all_checks"` + TotalIssues int `json:"total_issues"` + TotalDependencies int `json:"total_dependencies"` + OverallOK bool `json:"overall_ok"` +} + +// RunDeepValidation runs all deep validation checks on the issue graph. +// This may be slow on large databases. +func RunDeepValidation(path string) DeepValidationResult { + result := DeepValidationResult{ + OverallOK: true, + } + + // Follow redirect to resolve actual beads directory + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) + + // Get database path + var dbPath string + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + dbPath = cfg.DatabasePath(beadsDir) + } else { + dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) + } + + // Skip if database doesn't exist + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + check := DoctorCheck{ + Name: "Deep Validation", + Status: StatusOK, + Message: "N/A (no database)", + Category: CategoryMaintenance, + } + result.AllChecks = append(result.AllChecks, check) + return result + } + + // Open database + db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true)) + if err != nil { + check := DoctorCheck{ + Name: "Deep Validation", + Status: StatusError, + Message: "Unable to open database", + Detail: err.Error(), + Category: CategoryMaintenance, + } + result.AllChecks = append(result.AllChecks, check) + result.OverallOK = false + return result + } + defer db.Close() + + // Get counts for progress reporting + _ = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status != 'tombstone'").Scan(&result.TotalIssues) + _ = db.QueryRow("SELECT COUNT(*) FROM dependencies").Scan(&result.TotalDependencies) + + // Run all deep checks + result.ParentConsistency = checkParentConsistency(db) + result.AllChecks = append(result.AllChecks, result.ParentConsistency) + if result.ParentConsistency.Status == StatusError { + result.OverallOK = false + } + + result.DependencyIntegrity = checkDependencyIntegrity(db) + result.AllChecks = append(result.AllChecks, result.DependencyIntegrity) + if result.DependencyIntegrity.Status == StatusError { + result.OverallOK = false + } + + result.EpicCompleteness = checkEpicCompleteness(db) + result.AllChecks = append(result.AllChecks, result.EpicCompleteness) + if result.EpicCompleteness.Status == StatusWarning { + // Epic completeness is informational, not an error + } + + result.AgentBeadIntegrity = checkAgentBeadIntegrity(db) + result.AllChecks = append(result.AllChecks, result.AgentBeadIntegrity) + if result.AgentBeadIntegrity.Status == StatusError { + result.OverallOK = false + } + + result.MailThreadIntegrity = checkMailThreadIntegrity(db) + result.AllChecks = append(result.AllChecks, result.MailThreadIntegrity) + if result.MailThreadIntegrity.Status == StatusError { + result.OverallOK = false + } + + result.MoleculeIntegrity = checkMoleculeIntegrity(db) + result.AllChecks = append(result.AllChecks, result.MoleculeIntegrity) + if result.MoleculeIntegrity.Status == StatusError { + result.OverallOK = false + } + + return result +} + +// checkParentConsistency verifies that all parent-child dependencies point to existing issues +func checkParentConsistency(db *sql.DB) DoctorCheck { + check := DoctorCheck{ + Name: "Parent Consistency", + Category: CategoryMetadata, + } + + // Find parent-child deps where either side doesn't exist or is a tombstone + query := ` + SELECT d.issue_id, d.depends_on_id + FROM dependencies d + WHERE d.type = 'parent-child' + AND ( + NOT EXISTS (SELECT 1 FROM issues WHERE id = d.issue_id AND status != 'tombstone') + OR NOT EXISTS (SELECT 1 FROM issues WHERE id = d.depends_on_id AND status != 'tombstone') + ) + LIMIT 10` + + rows, err := db.Query(query) + if err != nil { + check.Status = StatusWarning + check.Message = "Unable to check parent consistency" + check.Detail = err.Error() + return check + } + defer rows.Close() + + var orphanedDeps []string + for rows.Next() { + var issueID, parentID string + if err := rows.Scan(&issueID, &parentID); err == nil { + orphanedDeps = append(orphanedDeps, fmt.Sprintf("%s→%s", issueID, parentID)) + } + } + + if len(orphanedDeps) == 0 { + check.Status = StatusOK + check.Message = "All parent-child relationships valid" + return check + } + + check.Status = StatusError + check.Message = fmt.Sprintf("Found %d orphaned parent-child dependencies", len(orphanedDeps)) + check.Detail = fmt.Sprintf("Examples: %s", strings.Join(orphanedDeps[:min(3, len(orphanedDeps))], ", ")) + check.Fix = "Run 'bd doctor --fix' to remove orphaned dependencies" + return check +} + +// checkDependencyIntegrity verifies that all dependencies point to existing issues +func checkDependencyIntegrity(db *sql.DB) DoctorCheck { + check := DoctorCheck{ + Name: "Dependency Integrity", + Category: CategoryMetadata, + } + + // Find any deps where either side is missing (excluding tombstones from check) + query := ` + SELECT d.issue_id, d.depends_on_id, d.type + FROM dependencies d + WHERE ( + NOT EXISTS (SELECT 1 FROM issues WHERE id = d.issue_id) + OR NOT EXISTS (SELECT 1 FROM issues WHERE id = d.depends_on_id) + ) + LIMIT 10` + + rows, err := db.Query(query) + if err != nil { + check.Status = StatusWarning + check.Message = "Unable to check dependency integrity" + check.Detail = err.Error() + return check + } + defer rows.Close() + + var brokenDeps []string + for rows.Next() { + var issueID, dependsOnID, depType string + if err := rows.Scan(&issueID, &dependsOnID, &depType); err == nil { + brokenDeps = append(brokenDeps, fmt.Sprintf("%s→%s (%s)", issueID, dependsOnID, depType)) + } + } + + if len(brokenDeps) == 0 { + check.Status = StatusOK + check.Message = "All dependencies point to existing issues" + return check + } + + check.Status = StatusError + check.Message = fmt.Sprintf("Found %d broken dependencies", len(brokenDeps)) + check.Detail = fmt.Sprintf("Examples: %s", strings.Join(brokenDeps[:min(3, len(brokenDeps))], ", ")) + check.Fix = "Run 'bd repair-deps' to remove broken dependencies" + return check +} + +// checkEpicCompleteness finds epics that could be closed (all children closed) +func checkEpicCompleteness(db *sql.DB) DoctorCheck { + check := DoctorCheck{ + Name: "Epic Completeness", + Category: CategoryMetadata, + } + + // Find epics where all children are closed but epic is still open + query := ` + SELECT e.id, e.title, + COUNT(c.id) as total_children, + SUM(CASE WHEN c.status = 'closed' THEN 1 ELSE 0 END) as closed_children + FROM issues e + JOIN dependencies d ON d.depends_on_id = e.id AND d.type = 'parent-child' + JOIN issues c ON c.id = d.issue_id AND c.status != 'tombstone' + WHERE e.issue_type = 'epic' + AND e.status NOT IN ('closed', 'tombstone') + GROUP BY e.id + HAVING total_children > 0 AND total_children = closed_children + LIMIT 20` + + rows, err := db.Query(query) + if err != nil { + check.Status = StatusWarning + check.Message = "Unable to check epic completeness" + check.Detail = err.Error() + return check + } + defer rows.Close() + + var completedEpics []string + for rows.Next() { + var id, title string + var total, closed int + if err := rows.Scan(&id, &title, &total, &closed); err == nil { + completedEpics = append(completedEpics, fmt.Sprintf("%s (%d/%d)", id, closed, total)) + } + } + + if len(completedEpics) == 0 { + check.Status = StatusOK + check.Message = "No epics eligible for closure" + return check + } + + check.Status = StatusWarning + check.Message = fmt.Sprintf("Found %d epics ready to close", len(completedEpics)) + check.Detail = fmt.Sprintf("Examples: %s", strings.Join(completedEpics[:min(5, len(completedEpics))], ", ")) + check.Fix = "Run 'bd close ' to close completed epics" + return check +} + +// agentBeadInfo holds info about an agent bead for integrity checking +type agentBeadInfo struct { + ID string + Title string + RoleBead string + AgentState string + RoleType string +} + +// checkAgentBeadIntegrity verifies that agent beads have required fields +func checkAgentBeadIntegrity(db *sql.DB) DoctorCheck { + check := DoctorCheck{ + Name: "Agent Bead Integrity", + Category: CategoryMetadata, + } + + // Check if agent bead columns exist (may not in older schemas) + var hasColumns bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 FROM pragma_table_info('issues') + WHERE name IN ('role_bead', 'agent_state', 'role_type') + `).Scan(&hasColumns) + if err != nil || !hasColumns { + check.Status = StatusOK + check.Message = "N/A (schema doesn't support agent beads)" + return check + } + + // Find agent beads missing required role_bead + // Note: We query JSON metadata from notes field or check for role_bead column + query := ` + SELECT id, title, + COALESCE(json_extract(notes, '$.role_bead'), '') as role_bead, + COALESCE(json_extract(notes, '$.agent_state'), '') as agent_state, + COALESCE(json_extract(notes, '$.role_type'), '') as role_type + FROM issues + WHERE issue_type = 'agent' + AND status != 'tombstone' + LIMIT 100` + + rows, err := db.Query(query) + if err != nil { + // Try alternate approach without JSON extraction + query = ` + SELECT id, title, '', '', '' + FROM issues + WHERE issue_type = 'agent' + AND status != 'tombstone' + LIMIT 100` + rows, err = db.Query(query) + if err != nil { + check.Status = StatusOK + check.Message = "No agent beads found" + return check + } + } + defer rows.Close() + + var agents []agentBeadInfo + var invalidAgents []string + + for rows.Next() { + var agent agentBeadInfo + if err := rows.Scan(&agent.ID, &agent.Title, &agent.RoleBead, &agent.AgentState, &agent.RoleType); err == nil { + agents = append(agents, agent) + + // Validate agent state + if agent.AgentState != "" { + state := types.AgentState(agent.AgentState) + if !state.IsValid() { + invalidAgents = append(invalidAgents, fmt.Sprintf("%s (invalid state: %s)", agent.ID, agent.AgentState)) + } + } + } + } + + if len(agents) == 0 { + check.Status = StatusOK + check.Message = "No agent beads to validate" + return check + } + + if len(invalidAgents) > 0 { + check.Status = StatusError + check.Message = fmt.Sprintf("Found %d agents with invalid data", len(invalidAgents)) + check.Detail = fmt.Sprintf("Examples: %s", strings.Join(invalidAgents[:min(3, len(invalidAgents))], ", ")) + check.Fix = "Update agent beads with valid agent_state values" + return check + } + + check.Status = StatusOK + check.Message = fmt.Sprintf("%d agent beads validated", len(agents)) + return check +} + +// checkMailThreadIntegrity verifies that mail thread_id references are valid +func checkMailThreadIntegrity(db *sql.DB) DoctorCheck { + check := DoctorCheck{ + Name: "Mail Thread Integrity", + Category: CategoryMetadata, + } + + // Check if thread_id column exists + var hasThreadID bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 FROM pragma_table_info('dependencies') + WHERE name = 'thread_id' + `).Scan(&hasThreadID) + if err != nil || !hasThreadID { + check.Status = StatusOK + check.Message = "N/A (schema doesn't support thread_id)" + return check + } + + // Find thread_ids that don't point to existing issues + query := ` + SELECT d.thread_id, COUNT(*) as refs + FROM dependencies d + WHERE d.thread_id != '' + AND d.thread_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM issues WHERE id = d.thread_id) + GROUP BY d.thread_id + LIMIT 10` + + rows, err := db.Query(query) + if err != nil { + check.Status = StatusWarning + check.Message = "Unable to check thread integrity" + check.Detail = err.Error() + return check + } + defer rows.Close() + + var orphanedThreads []string + totalOrphaned := 0 + for rows.Next() { + var threadID string + var refs int + if err := rows.Scan(&threadID, &refs); err == nil { + orphanedThreads = append(orphanedThreads, fmt.Sprintf("%s (%d refs)", threadID, refs)) + totalOrphaned += refs + } + } + + if len(orphanedThreads) == 0 { + check.Status = StatusOK + check.Message = "All thread references valid" + return check + } + + check.Status = StatusWarning + check.Message = fmt.Sprintf("Found %d orphaned thread references", totalOrphaned) + check.Detail = fmt.Sprintf("Threads: %s", strings.Join(orphanedThreads[:min(3, len(orphanedThreads))], ", ")) + check.Fix = "Clear orphaned thread_id values in dependencies" + return check +} + +// moleculeInfo holds info about a molecule for integrity checking +type moleculeInfo struct { + ID string + Title string + ChildCount int +} + +// checkMoleculeIntegrity verifies molecule structure integrity +func checkMoleculeIntegrity(db *sql.DB) DoctorCheck { + check := DoctorCheck{ + Name: "Molecule Integrity", + Category: CategoryMetadata, + } + + // Find molecules (issue_type='molecule' or has beads:template label) with broken structures + // A molecule should have parent-child relationships forming a valid DAG + + // First, find molecules + query := ` + SELECT DISTINCT i.id, i.title + FROM issues i + LEFT JOIN labels l ON l.issue_id = i.id + WHERE (i.issue_type = 'molecule' OR l.label = 'beads:template') + AND i.status != 'tombstone' + LIMIT 100` + + rows, err := db.Query(query) + if err != nil { + check.Status = StatusWarning + check.Message = "Unable to find molecules" + check.Detail = err.Error() + return check + } + defer rows.Close() + + var molecules []moleculeInfo + for rows.Next() { + var mol moleculeInfo + if err := rows.Scan(&mol.ID, &mol.Title); err == nil { + molecules = append(molecules, mol) + } + } + + if len(molecules) == 0 { + check.Status = StatusOK + check.Message = "No molecules to validate" + return check + } + + // For each molecule, check if all children exist + var brokenMolecules []string + for _, mol := range molecules { + // Count children that don't exist + var orphanCount int + err := db.QueryRow(` + SELECT COUNT(*) + FROM dependencies d + WHERE d.depends_on_id = ? + AND d.type = 'parent-child' + AND NOT EXISTS (SELECT 1 FROM issues WHERE id = d.issue_id AND status != 'tombstone') + `, mol.ID).Scan(&orphanCount) + if err == nil && orphanCount > 0 { + brokenMolecules = append(brokenMolecules, fmt.Sprintf("%s (%d missing children)", mol.ID, orphanCount)) + } + } + + if len(brokenMolecules) > 0 { + check.Status = StatusError + check.Message = fmt.Sprintf("Found %d molecules with missing children", len(brokenMolecules)) + check.Detail = fmt.Sprintf("Examples: %s", strings.Join(brokenMolecules[:min(3, len(brokenMolecules))], ", ")) + check.Fix = "Run 'bd repair-deps' to clean up orphaned relationships" + return check + } + + check.Status = StatusOK + check.Message = fmt.Sprintf("%d molecules validated", len(molecules)) + return check +} + +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// PrintDeepValidationResult prints the deep validation results +func PrintDeepValidationResult(result DeepValidationResult) { + fmt.Printf("\nDeep Validation Results\n") + fmt.Printf("========================\n") + fmt.Printf("Scanned: %d issues, %d dependencies\n\n", result.TotalIssues, result.TotalDependencies) + + for _, check := range result.AllChecks { + var icon string + switch check.Status { + case StatusOK: + icon = "\u2713" // checkmark + case StatusWarning: + icon = "!" + case StatusError: + icon = "\u2717" // X + } + fmt.Printf("[%s] %s: %s\n", icon, check.Name, check.Message) + if check.Detail != "" { + fmt.Printf(" %s\n", check.Detail) + } + if check.Fix != "" && check.Status != StatusOK { + fmt.Printf(" Fix: %s\n", check.Fix) + } + } + + fmt.Println() + if result.OverallOK { + fmt.Println("All deep validation checks passed!") + } else { + fmt.Println("Some checks failed. See details above.") + } +} + +// DeepValidationResultJSON returns the result as JSON bytes +func DeepValidationResultJSON(result DeepValidationResult) ([]byte, error) { + return json.MarshalIndent(result, "", " ") +} diff --git a/cmd/bd/doctor/deep_test.go b/cmd/bd/doctor/deep_test.go new file mode 100644 index 00000000..eb76279b --- /dev/null +++ b/cmd/bd/doctor/deep_test.go @@ -0,0 +1,302 @@ +package doctor + +import ( + "database/sql" + "os" + "path/filepath" + "testing" +) + +// TestRunDeepValidation_NoBeadsDir verifies deep validation handles missing .beads directory +func TestRunDeepValidation_NoBeadsDir(t *testing.T) { + tmpDir := t.TempDir() + result := RunDeepValidation(tmpDir) + + if len(result.AllChecks) != 1 { + t.Errorf("Expected 1 check, got %d", len(result.AllChecks)) + } + if result.AllChecks[0].Status != StatusOK { + t.Errorf("Status = %q, want %q", result.AllChecks[0].Status, StatusOK) + } +} + +// TestRunDeepValidation_EmptyBeadsDir verifies deep validation with empty .beads directory +func TestRunDeepValidation_EmptyBeadsDir(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + result := RunDeepValidation(tmpDir) + + // Should return OK with "no database" message + if len(result.AllChecks) != 1 { + t.Errorf("Expected 1 check, got %d", len(result.AllChecks)) + } + if result.AllChecks[0].Status != StatusOK { + t.Errorf("Status = %q, want %q", result.AllChecks[0].Status, StatusOK) + } +} + +// TestRunDeepValidation_WithDatabase verifies deep validation with a basic database +func TestRunDeepValidation_WithDatabase(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a minimal database (use canonical name beads.db) + dbPath := filepath.Join(beadsDir, "beads.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Create minimal schema matching what deep validation expects + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + issue_type TEXT NOT NULL DEFAULT 'task', + notes TEXT DEFAULT '' + ); + CREATE TABLE dependencies ( + issue_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'blocks', + created_by TEXT NOT NULL DEFAULT '', + thread_id TEXT DEFAULT '', + PRIMARY KEY (issue_id, depends_on_id) + ); + CREATE TABLE labels ( + issue_id TEXT NOT NULL, + label TEXT NOT NULL, + PRIMARY KEY (issue_id, label) + ); + `) + if err != nil { + t.Fatal(err) + } + + result := RunDeepValidation(tmpDir) + + // Should have 6 checks (one for each validation type) + if len(result.AllChecks) != 6 { + // Log what we got for debugging + t.Logf("Got %d checks:", len(result.AllChecks)) + for i, check := range result.AllChecks { + t.Logf(" %d: %s - %s", i, check.Name, check.Message) + } + t.Errorf("Expected 6 checks, got %d", len(result.AllChecks)) + } + + // All should pass on empty database + for _, check := range result.AllChecks { + if check.Status == StatusError { + t.Errorf("Check %s failed: %s", check.Name, check.Message) + } + } +} + +// TestCheckParentConsistency_OrphanedDeps verifies detection of orphaned parent-child deps +func TestCheckParentConsistency_OrphanedDeps(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, "beads.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Create schema + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + ); + CREATE TABLE dependencies ( + issue_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'blocks', + PRIMARY KEY (issue_id, depends_on_id) + ); + `) + if err != nil { + t.Fatal(err) + } + + // Insert an issue + _, err = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('bd-1', 'Test Issue', 'open')`) + if err != nil { + t.Fatal(err) + } + + // Insert a parent-child dep pointing to non-existent parent + _, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, type) VALUES ('bd-1', 'bd-missing', 'parent-child')`) + if err != nil { + t.Fatal(err) + } + + check := checkParentConsistency(db) + + if check.Status != StatusError { + t.Errorf("Status = %q, want %q", check.Status, StatusError) + } +} + +// TestCheckEpicCompleteness_CompletedEpic verifies detection of closeable epics +func TestCheckEpicCompleteness_CompletedEpic(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, "beads.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Create schema + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + issue_type TEXT NOT NULL DEFAULT 'task' + ); + CREATE TABLE dependencies ( + issue_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'blocks', + PRIMARY KEY (issue_id, depends_on_id) + ); + `) + if err != nil { + t.Fatal(err) + } + + // Insert an open epic + _, err = db.Exec(`INSERT INTO issues (id, title, status, issue_type) VALUES ('epic-1', 'Epic', 'open', 'epic')`) + if err != nil { + t.Fatal(err) + } + + // Insert a closed child task + _, err = db.Exec(`INSERT INTO issues (id, title, status, issue_type) VALUES ('task-1', 'Task', 'closed', 'task')`) + if err != nil { + t.Fatal(err) + } + + // Create parent-child relationship + _, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, type) VALUES ('task-1', 'epic-1', 'parent-child')`) + if err != nil { + t.Fatal(err) + } + + check := checkEpicCompleteness(db) + + // Epic with all children closed should be detected + if check.Status != StatusWarning { + t.Errorf("Status = %q, want %q", check.Status, StatusWarning) + } +} + +// TestCheckMailThreadIntegrity_ValidThreads verifies valid thread references pass +func TestCheckMailThreadIntegrity_ValidThreads(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, "beads.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Create schema with thread_id column + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + ); + CREATE TABLE dependencies ( + issue_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'blocks', + thread_id TEXT DEFAULT '', + PRIMARY KEY (issue_id, depends_on_id) + ); + `) + if err != nil { + t.Fatal(err) + } + + // Insert issues + _, err = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('thread-root', 'Thread Root', 'open')`) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('reply-1', 'Reply', 'open')`) + if err != nil { + t.Fatal(err) + } + + // Insert a dependency with valid thread_id + _, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, type, thread_id) VALUES ('reply-1', 'thread-root', 'replies-to', 'thread-root')`) + if err != nil { + t.Fatal(err) + } + + check := checkMailThreadIntegrity(db) + + if check.Status != StatusOK { + t.Errorf("Status = %q, want %q: %s", check.Status, StatusOK, check.Message) + } +} + +// TestDeepValidationResultJSON verifies JSON serialization +func TestDeepValidationResultJSON(t *testing.T) { + result := DeepValidationResult{ + TotalIssues: 10, + TotalDependencies: 5, + OverallOK: true, + AllChecks: []DoctorCheck{ + {Name: "Test", Status: StatusOK, Message: "All good"}, + }, + } + + jsonBytes, err := DeepValidationResultJSON(result) + if err != nil { + t.Fatalf("Failed to serialize: %v", err) + } + + if len(jsonBytes) == 0 { + t.Error("Expected non-empty JSON output") + } + + // Should contain expected fields + jsonStr := string(jsonBytes) + if !contains(jsonStr, "total_issues") { + t.Error("JSON should contain total_issues") + } + if !contains(jsonStr, "overall_ok") { + t.Error("JSON should contain overall_ok") + } +}