Files
poimen-workflows/internal/board/validator.go
T

383 lines
9.9 KiB
Go
Raw Normal View History

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
}