Table.Columns/Constraints/Indexes/Relationships are Go maps, and every writer, reader, diff, inspector, and merge code path that iterated them directly was subject to Go's randomized map order, so identical input could produce different output (or a different in-report violation/diff order) on every run. Most visibly this showed up as bun/gorm `unique:` struct tags changing order across consecutive `make models` runs with no source change. Fixed by sorting map iteration (by Sequence then Name, or alphabetically for string-keyed maps) everywhere the order affects generated output or first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb, pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx, prisma, and typeorm readers; the shared models.GetPrimaryKey/ GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the TUI column/relationship pickers in pkg/ui.
237 lines
5.8 KiB
Go
237 lines
5.8 KiB
Go
package inspector
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ANSI color codes
|
|
const (
|
|
colorReset = "\033[0m"
|
|
colorRed = "\033[31m"
|
|
colorYellow = "\033[33m"
|
|
colorGreen = "\033[32m"
|
|
colorBold = "\033[1m"
|
|
)
|
|
|
|
// ReportFormatter defines the interface for report formatters
|
|
type ReportFormatter interface {
|
|
Format(report *InspectorReport) (string, error)
|
|
}
|
|
|
|
// MarkdownFormatter formats reports as markdown
|
|
type MarkdownFormatter struct {
|
|
UseColors bool
|
|
}
|
|
|
|
// JSONFormatter formats reports as JSON
|
|
type JSONFormatter struct{}
|
|
|
|
// NewMarkdownFormatter creates a markdown formatter with color support detection
|
|
func NewMarkdownFormatter(writer io.Writer) *MarkdownFormatter {
|
|
return &MarkdownFormatter{
|
|
UseColors: isTerminal(writer),
|
|
}
|
|
}
|
|
|
|
// NewJSONFormatter creates a JSON formatter
|
|
func NewJSONFormatter() *JSONFormatter {
|
|
return &JSONFormatter{}
|
|
}
|
|
|
|
// Format generates a markdown report
|
|
func (f *MarkdownFormatter) Format(report *InspectorReport) (string, error) {
|
|
var sb strings.Builder
|
|
|
|
// Header
|
|
sb.WriteString(f.formatHeader("RelSpec Inspector Report"))
|
|
sb.WriteString("\n\n")
|
|
|
|
// Metadata
|
|
sb.WriteString(f.formatBold("Database:") + " " + report.Database + "\n")
|
|
sb.WriteString(f.formatBold("Source Format:") + " " + report.SourceFormat + "\n")
|
|
sb.WriteString(f.formatBold("Generated:") + " " + report.GeneratedAt.Format(time.RFC3339) + "\n")
|
|
sb.WriteString("\n")
|
|
|
|
// Summary
|
|
sb.WriteString(f.formatHeader("Summary"))
|
|
sb.WriteString("\n")
|
|
fmt.Fprintf(&sb, "- Rules Checked: %d\n", report.Summary.RulesChecked)
|
|
|
|
// Color-code error and warning counts
|
|
if report.Summary.ErrorCount > 0 {
|
|
sb.WriteString(f.colorize(fmt.Sprintf("- Errors: %d\n", report.Summary.ErrorCount), colorRed))
|
|
} else {
|
|
fmt.Fprintf(&sb, "- Errors: %d\n", report.Summary.ErrorCount)
|
|
}
|
|
|
|
if report.Summary.WarningCount > 0 {
|
|
sb.WriteString(f.colorize(fmt.Sprintf("- Warnings: %d\n", report.Summary.WarningCount), colorYellow))
|
|
} else {
|
|
fmt.Fprintf(&sb, "- Warnings: %d\n", report.Summary.WarningCount)
|
|
}
|
|
|
|
if report.Summary.PassedCount > 0 {
|
|
sb.WriteString(f.colorize(fmt.Sprintf("- Passed: %d\n", report.Summary.PassedCount), colorGreen))
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
|
|
// Group violations by level
|
|
errors := []ValidationResult{}
|
|
warnings := []ValidationResult{}
|
|
|
|
for _, v := range report.Violations {
|
|
if !v.Passed {
|
|
if v.Level == "error" {
|
|
errors = append(errors, v)
|
|
} else {
|
|
warnings = append(warnings, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Report violations
|
|
if len(errors) > 0 || len(warnings) > 0 {
|
|
sb.WriteString(f.formatHeader("Violations"))
|
|
sb.WriteString("\n")
|
|
|
|
// Errors
|
|
if len(errors) > 0 {
|
|
sb.WriteString(f.formatSubheader(fmt.Sprintf("Errors (%d)", len(errors)), colorRed))
|
|
sb.WriteString("\n")
|
|
for _, violation := range errors {
|
|
sb.WriteString(f.formatViolation(violation, colorRed))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Warnings
|
|
if len(warnings) > 0 {
|
|
sb.WriteString(f.formatSubheader(fmt.Sprintf("Warnings (%d)", len(warnings)), colorYellow))
|
|
sb.WriteString("\n")
|
|
for _, violation := range warnings {
|
|
sb.WriteString(f.formatViolation(violation, colorYellow))
|
|
}
|
|
}
|
|
} else {
|
|
sb.WriteString(f.colorize("✓ No violations found!\n", colorGreen))
|
|
}
|
|
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// Format generates a JSON report
|
|
func (f *JSONFormatter) Format(report *InspectorReport) (string, error) {
|
|
data, err := json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal report to JSON: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// Helper methods for MarkdownFormatter
|
|
|
|
func (f *MarkdownFormatter) formatHeader(text string) string {
|
|
return f.formatBold("# " + text)
|
|
}
|
|
|
|
func (f *MarkdownFormatter) formatSubheader(text string, color string) string {
|
|
header := "### " + text
|
|
if f.UseColors {
|
|
return color + colorBold + header + colorReset
|
|
}
|
|
return header
|
|
}
|
|
|
|
func (f *MarkdownFormatter) formatBold(text string) string {
|
|
if f.UseColors {
|
|
return colorBold + text + colorReset
|
|
}
|
|
return "**" + text + "**"
|
|
}
|
|
|
|
func (f *MarkdownFormatter) colorize(text string, color string) string {
|
|
if f.UseColors {
|
|
return color + text + colorReset
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (f *MarkdownFormatter) formatViolation(v ValidationResult, color string) string {
|
|
var sb strings.Builder
|
|
|
|
// Rule name as header
|
|
if f.UseColors {
|
|
sb.WriteString(color + "#### " + v.RuleName + colorReset + "\n")
|
|
} else {
|
|
sb.WriteString("#### " + v.RuleName + "\n")
|
|
}
|
|
|
|
// Location and message
|
|
sb.WriteString(f.formatBold("Location:") + " " + v.Location + "\n")
|
|
sb.WriteString(f.formatBold("Message:") + " " + v.Message + "\n")
|
|
|
|
// Context details (optional, only show interesting ones)
|
|
if len(v.Context) > 0 {
|
|
contextStr := f.formatContext(v.Context)
|
|
if contextStr != "" {
|
|
sb.WriteString(f.formatBold("Details:") + " " + contextStr + "\n")
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string {
|
|
// Extract relevant context information
|
|
var parts []string
|
|
|
|
// Skip schema, table, column as they're in location
|
|
skipKeys := map[string]bool{
|
|
"schema": true,
|
|
"table": true,
|
|
"column": true,
|
|
}
|
|
|
|
keys := make([]string, 0, len(context))
|
|
for key := range context {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, key := range keys {
|
|
if skipKeys[key] {
|
|
continue
|
|
}
|
|
|
|
parts = append(parts, fmt.Sprintf("%s=%v", key, context[key]))
|
|
}
|
|
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// isTerminal checks if the writer is a terminal (supports ANSI colors)
|
|
func isTerminal(w io.Writer) bool {
|
|
file, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
// Check if the file descriptor is a terminal
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Check if it's a character device (terminal)
|
|
// This works on Unix-like systems
|
|
return (stat.Mode() & os.ModeCharDevice) != 0
|
|
}
|