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
|
||
|
|
}
|