- Add internal/board package with validation and state tracking - Implement BoardValidator for comprehensive board file validation - Detect missing headers, malformed tables, invalid task IDs - Validate status fields ([x] or [ ]) - Parse task information from valid boards - Implement StateTracker for actual task state management - Track task progression (pending → in_progress → completed/failed) - Support task metrics attachment and analytics - Implement divergence detection: compare board vs actual states - Implement auto-healing: fix state mismatches between board and reality - RepairBoard() fixes structural corruption issues - HealDivergence() updates board to match actual states - Support both JSON persistence and in-memory operation Validation Features: - Detailed error reporting with line numbers and context - Warning system for suspicious but valid boards - Task ID format validation (T#.# pattern) - Status value normalization ([X] → [x]) - Table structure verification State Management: - Persistent JSON storage of task states - Completion/failure timestamps - Custom metrics per task - Thread-safe RWMutex synchronization - Stats and filtering operations Healing Features: - Non-destructive repairs (report changes) - Board integrity preservation - Divergence detection with timestamps - Batch update capability - Change tracking for audit trail Test Coverage: - 13 validator tests (structure, validation, repair, parsing) - 16 state tracker tests (tracking, persistence, analytics) - 29 total board tests, all passing - Edge cases: empty boards, invalid formats, multiple tasks - Multi-state transitions and metrics Key Design: - Separation of concerns: Validator (format) vs Tracker (state) - JSON persistence (human-readable, debuggable) - Thread-safe concurrent state updates - Detailed error messages with context - Non-breaking repairs (safe by default) Closes T1.4
383 lines
9.9 KiB
Go
383 lines
9.9 KiB
Go
package board
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// BoardValidationError represents a validation error
|
|
type BoardValidationError struct {
|
|
Type string // "missing_header", "invalid_row", "malformed_table", etc.
|
|
Message string
|
|
Line int
|
|
Context string
|
|
}
|
|
|
|
// BoardValidator validates and repairs board files
|
|
type BoardValidator struct {
|
|
boardPath string
|
|
errors []BoardValidationError
|
|
warnings []string
|
|
}
|
|
|
|
// NewBoardValidator creates a new board validator
|
|
func NewBoardValidator(boardPath string) *BoardValidator {
|
|
return &BoardValidator{
|
|
boardPath: boardPath,
|
|
errors: make([]BoardValidationError, 0),
|
|
warnings: make([]string, 0),
|
|
}
|
|
}
|
|
|
|
// TaskRow represents a parsed task row from the board
|
|
type TaskRow struct {
|
|
ID string
|
|
Description string
|
|
Status string // "[x]", "[ ]"
|
|
Branch string
|
|
Verification string
|
|
LineNo int
|
|
}
|
|
|
|
// ValidateBoard validates the board structure
|
|
func (bv *BoardValidator) ValidateBoard(content string) bool {
|
|
bv.errors = make([]BoardValidationError, 0)
|
|
bv.warnings = make([]string, 0)
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
// Check for required headers
|
|
if !bv.hasValidHeader(lines) {
|
|
bv.errors = append(bv.errors, BoardValidationError{
|
|
Type: "missing_header",
|
|
Message: "Board must have a valid markdown header",
|
|
})
|
|
return false
|
|
}
|
|
|
|
// Check for table separator
|
|
if !bv.hasTableSeparator(lines) {
|
|
bv.errors = append(bv.errors, BoardValidationError{
|
|
Type: "missing_table_separator",
|
|
Message: "Board must have a markdown table separator line (|---|---|...)",
|
|
})
|
|
return false
|
|
}
|
|
|
|
// Validate task rows
|
|
tableStartIdx := bv.findTableStart(lines)
|
|
if tableStartIdx >= 0 {
|
|
bv.validateTaskRows(lines[tableStartIdx:], tableStartIdx)
|
|
}
|
|
|
|
return len(bv.errors) == 0
|
|
}
|
|
|
|
// hasValidHeader checks if the board has a valid header
|
|
func (bv *BoardValidator) hasValidHeader(lines []string) bool {
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "#") && strings.Contains(line, "Task Board") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasTableSeparator checks if the board has a table separator
|
|
func (bv *BoardValidator) hasTableSeparator(lines []string) bool {
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "|") && strings.Contains(line, "-") && strings.Contains(line, "-|-") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// findTableStart finds the start of the task table
|
|
func (bv *BoardValidator) findTableStart(lines []string) int {
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "|") && !strings.Contains(line, "---") && !strings.Contains(line, "ID") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "|") && strings.Contains(line, "ID") {
|
|
return i + 2 // Skip header and separator
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// validateTaskRows validates all task rows in the table
|
|
func (bv *BoardValidator) validateTaskRows(lines []string, startIdx int) {
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || !strings.HasPrefix(line, "|") {
|
|
break
|
|
}
|
|
|
|
if strings.Contains(line, "---") {
|
|
continue // Skip separator
|
|
}
|
|
|
|
lineNo := startIdx + i
|
|
err := bv.validateTaskRow(line, lineNo)
|
|
if err.Message != "" {
|
|
bv.errors = append(bv.errors, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateTaskRow validates a single task row
|
|
func (bv *BoardValidator) validateTaskRow(line string, lineNo int) BoardValidationError {
|
|
parts := strings.Split(line, "|")
|
|
|
|
// Should have at least 6 parts: [empty, ID, Desc, Status, Branch, Verif, empty]
|
|
if len(parts) < 6 {
|
|
return BoardValidationError{
|
|
Type: "invalid_row",
|
|
Message: fmt.Sprintf("Invalid row format (expected at least 5 columns, got %d)", len(parts)-2),
|
|
Line: lineNo,
|
|
Context: line,
|
|
}
|
|
}
|
|
|
|
id := strings.TrimSpace(parts[1])
|
|
status := strings.TrimSpace(parts[3])
|
|
|
|
// Validate ID (should be T1.1 format or similar)
|
|
if !isValidTaskID(id) {
|
|
bv.warnings = append(bv.warnings, fmt.Sprintf("Line %d: Invalid task ID format: %s", lineNo, id))
|
|
}
|
|
|
|
// Validate status (should be [x] or [ ])
|
|
if status != "[x]" && status != "[ ]" && status != "[X]" {
|
|
return BoardValidationError{
|
|
Type: "invalid_status",
|
|
Message: fmt.Sprintf("Status must be '[x]' or '[ ]', got '%s'", status),
|
|
Line: lineNo,
|
|
Context: line,
|
|
}
|
|
}
|
|
|
|
return BoardValidationError{} // Valid
|
|
}
|
|
|
|
// isValidTaskID checks if a task ID is valid
|
|
func isValidTaskID(id string) bool {
|
|
// Match patterns like T0, T1.1, T1.2, etc.
|
|
pattern := regexp.MustCompile(`^T\d+(\.\d+)?$`)
|
|
return pattern.MatchString(id)
|
|
}
|
|
|
|
// ParseTasks parses all tasks from board content
|
|
func (bv *BoardValidator) ParseTasks(content string) ([]TaskRow, error) {
|
|
lines := strings.Split(content, "\n")
|
|
tasks := make([]TaskRow, 0)
|
|
|
|
tableStartIdx := bv.findTableStart(lines)
|
|
if tableStartIdx < 0 {
|
|
return nil, fmt.Errorf("no task table found")
|
|
}
|
|
|
|
for i := tableStartIdx; i < len(lines); i++ {
|
|
line := strings.TrimSpace(lines[i])
|
|
|
|
if line == "" || !strings.HasPrefix(line, "|") {
|
|
break
|
|
}
|
|
|
|
if strings.Contains(line, "---") {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 6 {
|
|
continue
|
|
}
|
|
|
|
task := TaskRow{
|
|
ID: strings.TrimSpace(parts[1]),
|
|
Description: strings.TrimSpace(parts[2]),
|
|
Status: strings.TrimSpace(parts[3]),
|
|
Branch: strings.TrimSpace(parts[4]),
|
|
Verification: strings.TrimSpace(parts[5]),
|
|
LineNo: i,
|
|
}
|
|
|
|
if task.ID != "" {
|
|
tasks = append(tasks, task)
|
|
}
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
// GetErrors returns validation errors
|
|
func (bv *BoardValidator) GetErrors() []BoardValidationError {
|
|
return bv.errors
|
|
}
|
|
|
|
// GetWarnings returns validation warnings
|
|
func (bv *BoardValidator) GetWarnings() []string {
|
|
return bv.warnings
|
|
}
|
|
|
|
// HasErrors checks if there are any errors
|
|
func (bv *BoardValidator) HasErrors() bool {
|
|
return len(bv.errors) > 0
|
|
}
|
|
|
|
// ErrorSummary returns a summary of errors
|
|
func (bv *BoardValidator) ErrorSummary() string {
|
|
if len(bv.errors) == 0 {
|
|
return "No errors found"
|
|
}
|
|
|
|
summary := fmt.Sprintf("Found %d error(s):\n", len(bv.errors))
|
|
for i, err := range bv.errors {
|
|
summary += fmt.Sprintf("%d. [Line %d] %s: %s\n", i+1, err.Line, err.Type, err.Message)
|
|
if err.Context != "" {
|
|
summary += fmt.Sprintf(" Context: %s\n", err.Context)
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// Warnings returns all warnings
|
|
func (bv *BoardValidator) WarningsSummary() string {
|
|
if len(bv.warnings) == 0 {
|
|
return "No warnings found"
|
|
}
|
|
|
|
summary := fmt.Sprintf("Found %d warning(s):\n", len(bv.warnings))
|
|
for i, warn := range bv.warnings {
|
|
summary += fmt.Sprintf("%d. %s\n", i+1, warn)
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// RepairBoard attempts to repair common board issues
|
|
func (bv *BoardValidator) RepairBoard(content string) (string, error) {
|
|
lines := strings.Split(content, "\n")
|
|
|
|
// Add header if missing
|
|
if !bv.hasValidHeader(lines) {
|
|
newLines := make([]string, 0)
|
|
newLines = append(newLines, "# Task Board — Milestone T1: Production Hardening")
|
|
newLines = append(newLines, "")
|
|
newLines = append(newLines, "**Submilestone:** T1 (Error recovery, observability, metrics, reliability)")
|
|
newLines = append(newLines, "")
|
|
newLines = append(newLines, lines...)
|
|
lines = newLines
|
|
}
|
|
|
|
// Add table separator if missing
|
|
if !bv.hasTableSeparator(lines) {
|
|
for i, line := range lines {
|
|
if strings.HasPrefix(line, "|") && strings.Contains(line, "ID") {
|
|
// Insert separator after header
|
|
newLines := make([]string, 0)
|
|
newLines = append(newLines, lines[:i+1]...)
|
|
newLines = append(newLines, "|---|---|---|---|---|")
|
|
newLines = append(newLines, lines[i+1:]...)
|
|
lines = newLines
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Repair invalid status values
|
|
for i, line := range lines {
|
|
if strings.Contains(line, "|") && !strings.Contains(line, "---|") {
|
|
// Replace invalid status markers
|
|
line = strings.ReplaceAll(line, "[ ]", "[ ]") // Normalize
|
|
line = strings.ReplaceAll(line, "[X]", "[x]") // Normalize
|
|
lines[i] = line
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
// BoardDivergence represents a difference between expected and actual state
|
|
type BoardDivergence struct {
|
|
TaskID string
|
|
ExpectedStatus string
|
|
ActualStatus string
|
|
DiscoveredAt time.Time
|
|
}
|
|
|
|
// DetectDivergence detects differences between expected and actual task states
|
|
func (bv *BoardValidator) DetectDivergence(content string, actualStates map[string]bool) []BoardDivergence {
|
|
tasks, err := bv.ParseTasks(content)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
divergences := make([]BoardDivergence, 0)
|
|
|
|
for _, task := range tasks {
|
|
expectedComplete := task.Status == "[x]"
|
|
actualComplete, exists := actualStates[task.ID]
|
|
|
|
if !exists {
|
|
// Task not in actual state - assume not complete
|
|
actualComplete = false
|
|
}
|
|
|
|
if expectedComplete != actualComplete {
|
|
divergences = append(divergences, BoardDivergence{
|
|
TaskID: task.ID,
|
|
ExpectedStatus: fmt.Sprintf("%v", expectedComplete),
|
|
ActualStatus: fmt.Sprintf("%v", actualComplete),
|
|
DiscoveredAt: time.Now(),
|
|
})
|
|
}
|
|
}
|
|
|
|
return divergences
|
|
}
|
|
|
|
// HealDivergence updates board to match actual state
|
|
func (bv *BoardValidator) HealDivergence(content string, actualStates map[string]bool) (string, []string, error) {
|
|
lines := strings.Split(content, "\n")
|
|
changes := make([]string, 0)
|
|
|
|
for i, line := range lines {
|
|
if !strings.HasPrefix(strings.TrimSpace(line), "|") || strings.Contains(line, "---") || strings.Contains(line, "ID") {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 4 {
|
|
continue
|
|
}
|
|
|
|
taskID := strings.TrimSpace(parts[1])
|
|
currentStatus := strings.TrimSpace(parts[3])
|
|
|
|
if actualState, exists := actualStates[taskID]; exists {
|
|
var expectedStatus string
|
|
if actualState {
|
|
expectedStatus = "[x]"
|
|
} else {
|
|
expectedStatus = "[ ]"
|
|
}
|
|
|
|
if currentStatus != expectedStatus {
|
|
// Update the status
|
|
parts[3] = " " + expectedStatus + " "
|
|
lines[i] = strings.Join(parts, "|")
|
|
changes = append(changes, fmt.Sprintf("Fixed %s: %s → %s", taskID, currentStatus, expectedStatus))
|
|
}
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n"), changes, nil
|
|
}
|