feat(doctor): add health check framework (gt-f9x.4)

Add doctor package with:
- Check interface for implementing health checks
- CheckContext for passing context to checks
- CheckResult and CheckStatus types
- Report with summary and pretty printing
- Doctor runner with Run() and Fix() methods
- BaseCheck and FixableCheck for easy check implementation
- CLI command: gt doctor [--fix] [--verbose] [--rig <name>]

Built-in checks will be added in:
- gt-f9x.5: Town-level checks (config, state, mail, rigs)
- gt-f9x.6: Rig-level checks (refinery, clones, gitignore)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-17 15:48:13 -08:00
parent f011e9bc80
commit d0f3ad9140
6 changed files with 956 additions and 193 deletions

118
internal/doctor/doctor.go Normal file
View File

@@ -0,0 +1,118 @@
package doctor
// Doctor manages and executes health checks.
type Doctor struct {
checks []Check
}
// NewDoctor creates a new Doctor with no registered checks.
func NewDoctor() *Doctor {
return &Doctor{
checks: make([]Check, 0),
}
}
// Register adds a check to the doctor's check list.
func (d *Doctor) Register(check Check) {
d.checks = append(d.checks, check)
}
// RegisterAll adds multiple checks to the doctor's check list.
func (d *Doctor) RegisterAll(checks ...Check) {
d.checks = append(d.checks, checks...)
}
// Checks returns the list of registered checks.
func (d *Doctor) Checks() []Check {
return d.checks
}
// Run executes all registered checks and returns a report.
func (d *Doctor) Run(ctx *CheckContext) *Report {
report := NewReport()
for _, check := range d.checks {
result := check.Run(ctx)
// Ensure check name is populated
if result.Name == "" {
result.Name = check.Name()
}
report.Add(result)
}
return report
}
// Fix runs all checks with auto-fix enabled where possible.
// It first runs the check, then if it fails and can be fixed, attempts the fix.
func (d *Doctor) Fix(ctx *CheckContext) *Report {
report := NewReport()
for _, check := range d.checks {
result := check.Run(ctx)
if result.Name == "" {
result.Name = check.Name()
}
// Attempt fix if check failed and is fixable
if result.Status != StatusOK && check.CanFix() {
err := check.Fix(ctx)
if err == nil {
// Re-run check to verify fix worked
result = check.Run(ctx)
if result.Name == "" {
result.Name = check.Name()
}
// Update message to indicate fix was applied
if result.Status == StatusOK {
result.Message = result.Message + " (fixed)"
}
} else {
// Fix failed, add error to details
result.Details = append(result.Details, "Fix failed: "+err.Error())
}
}
report.Add(result)
}
return report
}
// BaseCheck provides a base implementation for checks that don't support auto-fix.
// Embed this in custom checks to get default CanFix() and Fix() implementations.
type BaseCheck struct {
CheckName string
CheckDescription string
}
// Name returns the check name.
func (b *BaseCheck) Name() string {
return b.CheckName
}
// Description returns the check description.
func (b *BaseCheck) Description() string {
return b.CheckDescription
}
// CanFix returns false by default.
func (b *BaseCheck) CanFix() bool {
return false
}
// Fix returns an error indicating this check cannot be auto-fixed.
func (b *BaseCheck) Fix(ctx *CheckContext) error {
return ErrCannotFix
}
// FixableCheck provides a base implementation for checks that support auto-fix.
// Embed this and override CanFix() to return true, and implement Fix().
type FixableCheck struct {
BaseCheck
}
// CanFix returns true for fixable checks.
func (f *FixableCheck) CanFix() bool {
return true
}