feat: add bd doctor --deep for full graph integrity validation (bd-cwpl)
Add a --deep flag to bd doctor that runs comprehensive graph integrity checks on the beads database: - Parent consistency: verify parent-child deps point to existing issues - Dependency integrity: all dependencies reference valid issues - Epic completeness: find epics ready to close (all children closed) - Agent bead integrity: validate agent beads have valid state values - Mail thread integrity: verify thread_id references exist - Molecule integrity: check molecules have valid parent-child structures The deep validation mode warns about potential slowness on large databases and provides both human-readable and JSON output formats. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
|
||||
550
cmd/bd/doctor/deep.go
Normal file
550
cmd/bd/doctor/deep.go
Normal file
@@ -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 <epic-id>' 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, "", " ")
|
||||
}
|
||||
302
cmd/bd/doctor/deep_test.go
Normal file
302
cmd/bd/doctor/deep_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user