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>
303 lines
7.8 KiB
Go
303 lines
7.8 KiB
Go
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")
|
|
}
|
|
}
|